Merge branch 'stable-3.0'

* stable-3.0:
  Don't set workspace root in workspace-status.cmd
  Split up the git acceptance tests
  Set the workspace root through a JVM property sourceRoot
  Fix DbInjector creation in WebAppinitializer
  Swap dbInjector with cfgInjector
  Document how to use RBE for the gerrit build
  Remove the code for reading config from ReviewDb
  WORKSPACE: Remove non-reused JACKSON_VERSION constant

Change-Id: Id4b1eef06ec60d64b46e62411d9f039185476aa7
diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt
index fb35dc2..1dd6720 100644
--- a/Documentation/cmd-ls-projects.txt
+++ b/Documentation/cmd-ls-projects.txt
@@ -122,16 +122,16 @@
 $ ssh -p 29418 review.example.com gerrit ls-projects
 platform/manifest
 tools/gerrit
-tools/gwtorm
+tools/gitiles
 
 $ curl http://review.example.com/projects/
 platform/manifest
 tools/gerrit
-tools/gwtorm
+tools/gitiles
 
 $ curl http://review.example.com/projects/tools/
 tools/gerrit
-tools/gwtorm
+tools/gitiles
 ----
 
 Clone any project visible to the user:
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index af83fad..67cd0f9 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1215,6 +1215,25 @@
 +
 By default 500.
 
+[[change.maxUpdates]]change.maxUpdates::
++
+Maximum number of updates to a change. Counts only updates to the main NoteDb
+meta ref; draft comments, robot comments, stars, etc. do not count towards the
+total.
++
+Many NoteDb operations require walking the entire change meta ref and loading
+its contents into memory, so changes with arbitrarily many updates may cause
+high CPU usage, memory pressure, persistent cache bloat, and other problems.
++
+The following operations are allowed even when a change is at the limit:
+* Abandon
+* Submit
+* Submit by push with `%submit`
+* Auto-close by pushing directly to the branch
+* Fix with link:rest-api-changes.html#fix-input[`expect_merged_as`]
++
+By default 1000.
+
 [[change.replyLabel]]change.replyLabel::
 +
 Label name for the reply button. In the user interface an ellipsis (…)
@@ -1558,6 +1577,10 @@
 [[core]]
 === Section core
 
+[NOTE]
+The link:#jgitConfig[etc/jgit.config] file supports configuration of all JGit
+options.
+
 [[core.packedGitWindowSize]]core.packedGitWindowSize::
 +
 Number of bytes of a pack file to load into memory in a single
@@ -4905,6 +4928,19 @@
 +
 * link:config-themes.html[Themes]
 
+[[jgitConfig]]
+== File `etc/jgit.config`
+
+Gerrit uses the `$site_path/etc/jgit.config` file instead of the
+system-wide and user-global Git configuration for its runtime JGit
+configuration.
+
+Sample `etc/jgit.config` file:
+----
+[core]
+  trustFolderStat = false
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-login-register.txt b/Documentation/config-login-register.txt
index 3dcef0a..cc2185b 100644
--- a/Documentation/config-login-register.txt
+++ b/Documentation/config-login-register.txt
@@ -57,7 +57,7 @@
 find the url in the settings file.
 
 ----
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config gerrit.canonicalWebUrl
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config gerrit.canonicalWebUrl
   http://localhost:8080/
   gerrit@host:~$
 ----
@@ -70,9 +70,9 @@
 proxy settings in the configuration file.
 
 ----
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxy http://proxy:8080
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyUsername username
-  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyPassword password
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxy http://proxy:8080
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyUsername username
+  gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyPassword password
 ----
 
 Refer to the Gerrit configuration guide for more detailed information about
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 39d4b7c..c37e9d6 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -8,7 +8,7 @@
 * A Linux or macOS system (Windows is not supported at this time)
 * A JDK for Java 8|9|10|11|...
 * Python 2 or 3
-* Node.js
+* Node.js (including npm)
 * link:https://www.bazel.io/versions/master/docs/install.html[Bazel]
 * Maven
 * zip, unzip
@@ -17,6 +17,19 @@
 [[java]]
 === Java
 
+==== MacOS
+
+On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
+and that `JAVA_HOME` is set to the
+link:install.html#Requirements[required Java version].
+
+Java installations can typically be found in
+"/System/Library/Frameworks/JavaVM.framework/Versions".
+
+To check the installed version of Java, open a terminal window and run:
+
+`java -version`
+
 [[java-10]]
 ==== Java 10 support
 
@@ -94,6 +107,9 @@
   javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
 ```
 
+=== Node.js and npm packages
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages].
+
 [[build]]
 == Building on the Command Line
 
@@ -105,10 +121,6 @@
   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:
 
 ----
@@ -381,16 +393,16 @@
 
 == 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
+To build against unpublished Maven JARs, like 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',
+   name = 'prolog-runtime',
+   artifact = 'com.googlecode.prolog-cafe:prolog-runtime:42',
    repository = MAVEN_LOCAL,
  )
 ----
@@ -437,6 +449,19 @@
  )
 ----
 
+== Building against SNAPSHOT Maven JARs
+
+To build against SNAPSHOT Maven JARs, the complete SNAPSHOT version must be used:
+
+[source,python]
+----
+ maven_jar(
+   name = "pac4j-core",
+   artifact = "org.pac4j:pac4j-core:3.5.0-SNAPSHOT-20190112.120241-16",
+   sha1 = "da2b1cb68a8f87bfd40813179abd368de9f3a746",
+ )
+----
+
 [[consume-jgit-from-development-tree]]
 
 To consume the JGit dependency from the development tree, edit
diff --git a/Documentation/dev-cla.txt b/Documentation/dev-cla.txt
new file mode 100644
index 0000000..3311d49
--- /dev/null
+++ b/Documentation/dev-cla.txt
@@ -0,0 +1,26 @@
+= Gerrit Code Review - Contributor License Agreement
+
+In order to link::dev-community.html#how-to-contribute[contribute] to
+Gerrit a Contributor License Agreement must be completed before
+contributions are accepted. To view and accept the agreements do the
+following:
+
+. Click 'Sign In' at the top right corner of
+  https://gerrit-review.googlesource.com/
+. Sign In with your Google account
+. After signing in, go to the
+  link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements]
+  tab on the settings page
+. Click on 'New Contributor Agreement' and follow the instructions
+
+For reference, the actual agreements are linked below
+
+* link:https://cla.developers.google.com/about/android-individual[Individual Agreement]
+* link:https://source.android.com/source/cla-corporate.pdf[Corporate Agreement]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
new file mode 100644
index 0000000..52e13c4
--- /dev/null
+++ b/Documentation/dev-community.txt
@@ -0,0 +1,70 @@
+= Gerrit Community
+
+Gerrit is developed as a
+link:https://gerrit-review.googlesource.com/[self-hosting open source project]
+and very much welcomes contributions from anyone with a
+link:dev-cla.html[contributor's agreement] on file with the project.
+
+[[project-information]]
+== Project Information
+
+* link:https://www.gerritcodereview.com/[Project Homepage]
+* link:https://www.gerritcodereview.com/releases-readme.html[Release Versions]
+* link:https://gerrit.googlesource.com/gerrit[Source]
+* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
+* link:https://gerrit-review.googlesource.com/q/status:open+project:gerrit[Change Review]
+* link:dev-design.html[System Design]
+* Processes
+** link:dev-processes.html#project-governance[Project Governance / Steering Committee]
+** link:dev-contributing.html#contribution-processes[Contribution Processes]
+*** link:dev-contributing.html#lightweight-contribution-process[Lightweight Contribution Process]
+*** link:dev-contributing.html#design-driven-contribution-process[Design-Driven Contribution Process]
+*** link:dev-contributing.html#mentorship[Mentorship]
+** link:dev-design-docs.html#review[Design doc reviews]
+** link:dev-processes.html#dev-in-stable-branches[Development in stable branches]
+** link:dev-processes.html#backporting[Backporting to stable branches]
+** link:dev-processes.html#upgrading-libraries[Upgrading Libraries]
+** link:dev-processes.html#deprecating-features[Deprecating features]
+* Roles
+** link:dev-roles.html#supporter[Supporter]
+** link:dev-roles.html#contributor[Contributor]
+** link:dev-roles.html#maintainer[Maintainer]
+** link:dev-roles.html#mentor[Mentor]
+** link:dev-roles.html#steering-committee-member[Steering Committee Member]
+** link:dev-roles.html#community-manager[Community Manager]
+** link:dev-roles.html#release-manager[Release Manager]
+
+[[how-to-contribute]]
+== How to contribute?
+* link:dev-cla.html[Contributor License Agreement]
+* link:dev-contributing.html#contribution-processes[Contribution Processes]
+** link:dev-contributing.html#lightweight-contribution-process[Lightweight Contribution Process]
+** link:dev-contributing.html#design-driven-contribution-process[Design-Driven Contribution Process]
+** link:dev-contributing.html#mentorship[Mentorship]
+* link:dev-design-docs.html[Design Docs]
+* link:dev-readme.html[Developer Setup]
+* link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui[Polymer Frontend Developer Setup]
+* link:dev-crafting-changes.html[Crafting Changes]
+* link:dev-starter-projects.html[Starter Projects]
+
+[[plugin-development]]
+== Plugin Development
+* link:dev-plugins.html[Developing Plugins]
+* link:dev-build-plugins.html[Building Gerrit plugins]
+* link:js-api.html[JavaScript Plugin API]
+* link:config-validation.html[Validation Interfaces]
+* link:dev-stars.html[Starring Changes]
+* link:quota.html[Quota Enforcement]
+
+[[maintainer]]
+== Maintainer
+* link:dev-release.html[Making a Gerrit Release]
+* link:dev-release-subproject.html[Making a Release of a Gerrit Subproject]
+* link:https://www.gerritcodereview.com/publishing.html[Publish Gerrit Homepage]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index a15d98e8..0bac643 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -1,415 +1,248 @@
 = Gerrit Code Review - Contributing
 
-== Introduction
-Gerrit is developed as a
-link:https://gerrit-review.googlesource.com/[self-hosting open source project]
-and very much welcomes contributions from anyone with a contributor's
-agreement on file with the project.
-
+[[cla]]
 == Contributor License Agreement
-A Contributor License Agreement must be completed before contributions
-are accepted.  To view and accept the agreements do the following:
 
-* Click 'Sign In' at the top right corner of https://gerrit-review.googlesource.com/
-* Sign In with your Google account
-* After signing in, go to the
-link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements]
-tab on the settings page
-* Click 'New Contributor Agreement' and follow the instructions
+In order to contribute to Gerrit a link:dev-cla.html[Contributor
+License Agreement] must be completed before contributions are accepted.
 
-For reference, the actual agreements are linked below
+[[contribution-processes]]
+== Contribution Processes
 
-* link:https://cla.developers.google.com/about/android-individual[Individual Agreement]
-* link:https://source.android.com/source/cla-corporate.pdf[Corporate Agreement]
+The Gerrit project offers two contribution processes:
 
-== Code Review
+* link:#lightweight-contribution-process[Lightweight Contribution
+  Process]
+* link:#design-driven-contribution-process[Design-Driven Contribution
+  Process]
+
+The lightweight contribution process has little overhead and is best
+suited for small contributions (documentation updates, bug fixes, small
+features). Contributions are pushed as changes and
+link:dev-roles.html#maintainer[maintainers] review them adhoc.
+
+For large/complex features, it is required to follow the
+link:#design-driven-contribution-process[design-driven contribution
+process] and specify the feature in a link:dev-design-docs.html[design
+doc] before starting with the implementation.
+
+If link:dev-roles.html#contributor[contributors] choose the
+lightweight contribution process and during the review it turns out
+that the feature is too large or complex,
+link:dev-roles.html#maintainer[maintainers] can require to follow the
+design-driven contribution process instead.
+
+If you are in doubt which process is right for you, consult the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list.
+
+These contribution processes apply to everyone who contributes code to
+the Gerrit project, including link:dev-roles.html#maintainer[
+maintainers]. When reading this document, keep in mind that maintainers
+are also contributors when they contribute code.
+
+If a new feature is large or complex, it is often difficult to find a
+maintainer who can take the time that is needed for a thorough review,
+and who can help with getting the changes submitted. To avoid that this
+results in unpredictable long waiting times during code review,
+contributors can ask for link:#mentorship[mentor support]. A mentor
+helps with timely code reviews and technical guidance. Doing the
+implementation is still the responsibility of the contributor.
+
+[[comparison]]
+=== Quick Comparison
+
+[options="header"]
+|======================
+|        |Lightweight Contribution Process|Design-Driven Contribution Process
+|Overhead|low (write good commit message, address review comments)|
+high (write link:dev-design-docs.html[design doc] and get it approved)
+|Technical Guidance|by reviewer|during the design review and by
+reviewer/mentor
+|Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
+a link:#mentorship[mentor] was assigned)
+|Caveats |features may get vetoed after the implementation was already
+done, maintainers may make the design-driven contribution process
+required if a change gets too complex/large|design doc must stay open
+for a minimum of 10 calendar days, a mentor may not be available
+immediately
+|Applicable to|documentation updates, bug fixes, small features|
+large/complex features
+|======================
+
+[[lightweight-contribution-process]]
+=== Lightweight Contribution Process
+
+The lightweight contribution process has little overhead and is best
+suited for small contributions (documentation updates, bug fixes, small
+features). For large/complex features the
+link:#design-driven-contribution-process[design-driven contribution
+process] is required.
+
 As Gerrit is a code review tool, naturally contributions will
 be reviewed before they will get submitted to the code base.  To
 start your contribution, please make a git commit and upload it
-for review to the main Gerrit review server.  To help speed up the
-review of your change, review these guidelines before submitting
-your change.  You can view the pending Gerrit contributions and
-their statuses
+for review to the link:https://gerrit-review.googlesource.com/[
+gerrit-review.googlesource.com] Gerrit server.  To help speed up the
+review of your change, review these link:dev-crafting-changes.html[
+guidelines] before submitting your change.  You can view the pending
+Gerrit contributions and their statuses
 link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here].
 
 Depending on the size of that list it might take a while for
 your change to get reviewed.  Naturally there are fewer
-approvers than contributors; so anything that you can do to
-ensure that your contribution will undergo fewer revisions
-will speed up the contribution process.  This includes helping
-out reviewing other people's changes to relieve the load from
-the approvers.  Even if you are not familiar with Gerrit's
-internals, it would be of great help if you can download, try
-out, and comment on new features.  If it works as advertised,
-say so, and if you have the privileges to do so, go ahead
-and give it a +1 Verified.  If you would find the feature
-useful, say so and give it a +1 code review.
+link:dev-roles.html#maintainer[maintainers], that can approve changes,
+than link:dev-roles.html#contributor[contributors]; so anything that
+you can do to ensure that your contribution will undergo fewer
+revisions will speed up the contribution process.  This includes
+helping out reviewing other people's changes to relieve the load from
+the maintainers.  Even if you are not familiar with Gerrit's internals,
+it would be of great help if you can download, try out, and comment on
+new features.  If it works as advertised, say so, and if you have the
+privileges to do so, go ahead and give it a `+1 Verified`.  If you
+would find the feature useful, say so and give it a `+1 Code Review`.
 
-And finally, the quicker you respond to the comments of your
-reviewers, the quicker your change might get merged!  Try to
-reply to every comment after submitting your new patch,
-particularly if you decided against making the suggested change.
-Reviewers don't want to seem like nags and pester you if you
-haven't replied or made a fix, so it helps them know if you
-missed it or decided against it.
+And finally, the quicker you respond to the comments of your reviewers,
+the quicker your change might get merged!  Try to reply to every
+comment after submitting your new patch, particularly if you decided
+against making the suggested change. Reviewers don't want to seem like
+nags and pester you if you haven't replied or made a fix, so it helps
+them know if you missed it or decided against it.
 
+[[design-driven-contribution-process]]
+=== Design-driven Contribution Process
 
-== Review Criteria
+The design-driven contribution process applies to large/complex
+features.
 
-Here are some hints as to what approvers may be looking for
-before approving or submitting changes to the Gerrit project.
-Let's start with the simple nit picky stuff.  You are likely
-excited that your code works; help us share your excitement
-by not distracting us with the simple stuff.  Thanks to Gerrit,
-problems are often highlighted and we find it hard to look
-beyond simple spacing issues.  Blame it on our short attention
-spans, we really do want your code.
+For large/complex features it is important to:
 
+* agree on the functionality and scope before spending too much time
+  on the implementation
+* ensure that they are in line with Gerrit's project scope and vision
+* ensure that they are well aligned with other features
+* think about possibilities how the feature could be evolved over time
 
-[[commit-message]]
-=== Commit Message
+This is why for large/complex features it is required to describe the
+feature in a link:dev-design-docs.html[design doc] and get it approved
+by the link:dev-processes.html#steering-committee[steering committee],
+before starting the implementation.
 
-It is essential to have a good commit message if you want your
-change to be reviewed.
+The design-driven contribution process has the following steps:
 
-  * Keep lines no longer than 72 chars
-  * Start with a short one line summary
-  * Followed by a blank line
-  * Followed by one or more explanatory paragraphs
-  * Use the present tense (fix instead of fixed)
-  * Use the past tense when describing the status before this commit
-  * Include a `Bug: Issue <#>` line if fixing a Gerrit issue, or a
-    `Feature: Issue <#>` line if implementing a feature request.
-  * Include a `Change-Id` line
+* A link:dev-roles.html#contributor[contributor]
+  link:dev-design-docs.html#propose[proposes] a new feature by
+  uploading a change with a link:dev-design-docs.html[design doc].
+* The design doc is link:dev-design-docs.html#review[reviewed] by
+  interested parties from the community. The design review is public
+  and everyone can comment and raise concerns.
+* Design docs should stay open for a minimum of 10 calendar days so
+  that everyone has a fair chance to join the review.
+* Within 14 calendar days the contributor should hear back from the
+  link:dev-processes.html#steering-committee[steering committee]
+  whether the proposed feature is in scope of the project and if it can
+  be accepted.
+* To be submitted, the design doc needs to be approved by the
+  link:dev-processes.html#steering-committee[steering committee].
+* After the design was approved, the implementation is done by pushing
+  changes for review, see link:#lightweight-contribution-process[
+  lightweight contribution process]. Changes that are associated with
+  a design should all share a common hashtag. The contributor is the
+  main driver of the implementation and responsible that it is done.
+  Others from the Gerrit community are usually much welcome to help
+  with the implementation.
 
-=== Setting up Vim for Git commit message
+In order to be accepted/submitted, it is not necessary that the design
+doc fully specifies all the details, but the idea of the feature and
+how it fits into Gerrit should be sufficiently clear (judged by the
+steering committee). Contributors are expected to keep the design doc
+updated and fill in gaps while they go forward with the implementation.
+We expect that implementing the feature and updating the design doc
+will be an iterative process.
 
-Git uses Vim as the default commit message editor. Put this into your
-`$HOME/.vimrc` file to configure Vim for Git commit message formatting
-and writing:
+While the design doc is still in review, contributors may already start
+with the implementation (e.g. do some prototyping to demonstrate parts
+of the proposed design), but those changes should not be submitted
+while the design wasn't approved yet.
 
-====
-  " Enable spell checking, which is not on by default for commit messages.
-  au FileType gitcommit setlocal spell
+By approving a design, the steering committee commits to:
 
-  " Reset textwidth if you've previously overridden it.
-  au FileType gitcommit setlocal textwidth=72
-====
+* Accepting the feature when it is implemented.
+* Supporting the feature by assigning a link:dev-roles.html#mentor[
+  mentor] (if requested, see link:#mentorship[mentorship]).
 
+If the implementation of a feature gets stuck and it's unclear whether
+the feature gets fully done, it should be discussed with the steering
+committee how to proceed. If the contributor cannot commit to finish
+the implementation and no other contributor can take over, changes that
+have already been submitted for the feature might get reverted so that
+there is no unused or half-finished code in the code base.
 
-[[git_commit_settings]]
-=== A sample good Gerrit commit message:
-====
-  Add sample commit message to guidelines doc
+For contributors, the design-driven contribution process has the
+following advantages:
 
-  The original patch set for the contributing guidelines doc did not
-  include a sample commit message, this new patchset does.  Hopefully this
-  makes things a bit clearer since examples can sometimes help when
-  explanations don't.
+* By writing a design doc, the feature gets more attention. During the
+  design review, feedback from various sides can be collected, which
+  likely leads to improvements of the feature.
+* Once a design was approved by the
+  link:dev-processes.html#steering-committee[steering committee], the
+  contributor can be almost certain that the feature will be accepted.
+  Hence, there is only a low risk to invest into implementing a feature
+  and see it being rejected later during the code review, as it can
+  happen with the lightweight contribution process.
+* The contributor can link:#mentorship[get a dedicated mentor assigned]
+  who provides timely reviews and serves as a contact person for
+  technical questions and discussing details of the design.
 
-  Note that the body of this commit message can be several paragraphs, and
-  that I word wrap it at 72 characters.  Also note that I keep the summary
-  line under 50 characters since it is often truncated by tools which
-  display just the git summary.
+[[mentorship]]
+== Mentorship
 
-  Bug: Issue 98765605
-  Change-Id: Ic4a7c07eeb98cdeaf44e9d231a65a51f3fceae52
-====
+For features for which a link:dev-design-docs.html[design] has been
+approved (see link:#design-driven-contribution-process[design-driven
+contribution process]), contributors can gain the support of a mentor
+if they are committed to implement the feature.
 
-The `Change-Id` line is, as usual, created by a local git hook.  To install it,
-simply copy it from the checkout and make it executable:
+A link:dev-roles.html#mentor[mentor] helps with:
 
-====
-  cp ./gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg .git/hooks/
-  chmod +x .git/hooks/commit-msg
-====
+* doing timely reviews
+* providing technical guidance during code reviews
+* discussing details of the design
+* ensuring that the quality standards are met (well documented,
+  sufficient test coverage, backwards compatible etc.)
 
-If you are working on core plugins, you will also need to install the
-same hook in the submodules:
+A feature can have more than one mentor. To be able to deliver the
+promised support, at least one of the mentors must be a
+link:dev-roles.html#maintainer[maintainer].
 
-====
-  export hook=$(pwd)/.git/hooks/commit-msg
-  git submodule foreach 'cp -p "$hook" "$(git rev-parse --git-dir)/hooks/"'
-====
+Mentors are assigned by the link:dev-processes.html#steering-committee[
+steering committee]. To gain a mentor, ask for a mentor in the
+link:dev-design-doc-template.html#implementation-plan[Implementation
+Plan] section of the design doc or ask the steering committee after the
+design has been approved.
 
+Mentors may not be available immediately. In this case, the steering
+committee should include the approved feature into the roadmap or
+prioritize it in the backlog. This way, it is transparent for the
+contributor when they can expect to be able to work on the feature with
+mentor support.
 
-To set up git's remote for easy pushing, run the following:
+Once the implementation phase starts, the contributor is expected to do
+the implementation in a timely manner.
 
-====
-  git remote add gerrit https://gerrit.googlesource.com/gerrit
-====
+For every mentorship, the end must be clearly defined. The design doc
+must specify:
 
-The HTTPS access requires proper username and password; this can be obtained
-by clicking the 'Obtain Password' link on the
-link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
-Password tab of the user settings page].
+* a maximum time frame for the mentorship, after which the mentorship
+  automatically ends, even if the feature is not done yet
+* done criteria that define when the feature is done and the mentorship
+  ends
 
-[[style]]
-=== Style
-
-This project has a policy of Eclipse's warning free code. Eclipse
-configuration is added to git and we expect the changes to be
-warnings free.
-
-We do not ask you to use Eclipse for editing, obviously.  We do ask you
-to provide Eclipse's warning free patches only. If for some reasons, you
-are not able to set up Eclipse and verify, that your patch hasn't
-introduced any new Eclipse warnings, mention this in a comment to your
-change, so that reviewers will do it for you. Yes, the way to go is to
-extend gerrit CI to take care of this, but it's not yet implemented.
-
-Gerrit generally follows the
-link:https://google.github.io/styleguide/javaguide.html[Google Java Style
-Guide].
-
-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.22.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.
-
-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
-stick to the blank line conventions.  Here are some specific
-examples:
-
-  * Keep a blank line between all class and method declarations.
-  * Do not add blank lines at the beginning or end of class/methods.
-
-When to use `final` modifier and when not (in new code):
-
-Always:
-
-  * final fields: marking fields as final forces them to be
-  initialized in the constructor or at declaration
-  * final static fields: clearly communicates the intent
-  * to use final variables in inner anonymous classes
-
-Optional:
-
-  * final classes: use when appropriate, e.g. API restriction
-  * final methods: similar to final classes
-
-Never:
-
-  * 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
-
-=== Code Organization
-
-Do your best to organize classes and methods in a logical way.
-Here are some guidelines that Gerrit uses:
-
-  * Ensure a standard copyright header is included at the top
-    of any new files (copy it from another file, update the year).
-  * Always place loggers first in your class!
-  * Define any static interfaces next in your class.
-  * Define non static interfaces after static interfaces in your
-    class.
-  * 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).
-  * Getters and setters for the same instance field should usually
-    be near each other barring a good reason not to.
-  * If you are using assisted injection, the factory for your class
-    should be before the instance members.
-  * Annotations should go before language keywords (`final`, `private`, etc) +
-    Example: `@Assisted @Nullable final type varName`
-  * 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.
-
-Wow that's a lot!  But don't worry, you'll get the habit and most
-of the code is organized this way already; so if you pay attention
-to the class you are editing you will likely pick up on it.
-Naturally new classes are a little harder; you may want to come
-back and consult this section when creating them.
-
-
-=== Design
-
-Here are some design level objectives that you should keep in mind
-when coding:
-
-  * Most client pages should perform only one RPC to load so as to
-    keep latencies down.  Exceptions would apply to RPCs which need
-    to load large data sets if splitting them out will help the
-    page load faster.  Generally page loads are expected to complete
-    in under 100ms.  This will be the case for most operations,
-    unless the data being fetched is not using Gerrit's caching
-    infrastructure.  In these slower cases, it is worth considering
-    mitigating this longer load by using a second RPC to fill in
-    this data after the page is displayed (or alternatively it might
-    be worth proposing caching this data).
-  * `@Inject` should be used on constructors, not on fields.  The
-    current exceptions are the ssh commands, these were implemented
-    earlier in Gerrit's development.  To stay consistent, new ssh
-    commands should follow this older pattern; but eventually these
-    should get converted to eliminate this exception.
-  * Don't leave repository objects (git or schema) open.  A .close()
-    after every open should be placed in a finally{} block.
-  * Don't leave UI components, which can cause new actions to occur,
-    enabled during RPCs which update Git repositories, including NoteDb.
-    This is to prevent people from submitting actions more than once
-    when operating on slow links.  If the action buttons are disabled,
-    they cannot be resubmitted and the user can see that Gerrit is still
-    busy.
-  * ...and so is Guava (previously known as Google Collections).
-
-
-=== Tests
-
-  * Tests for new code will greatly help your change get approved.
-
-
-=== Change Size/Number of Files Touched
-
-And finally, I probably cannot say enough about change sizes.
-Generally, smaller is better, hopefully within reason.  Do try to
-keep things which will be confusing on their own together,
-especially if changing one without the other will break something!
-
-  * If a new feature is implemented and it is a larger one, try to
-    identify if it can be split into smaller logical features; when
-    in doubt, err on the smaller side.
-  * Separate bug fixes from feature improvements.  The bug fix may
-    be an easy candidate for approval and should not need to wait
-    for new features to be approved.  Also, combining the two makes
-    reviewing harder since then there is no clear line between the
-    fix and the feature.
-  * Separate supporting refactoring from feature changes.  If your
-    new feature requires some refactoring, it helps to make the
-    refactoring a separate change which your feature change
-    depends on.  This way, reviewers can easily review the refactor
-    change as a something that should not alter the current
-    functionality, and feel more confident they can more easily
-    spot errors this way.  Of course, it also makes it easier to
-    test and locate later on if an unfortunate error does slip in.
-    Lastly, by not having to see refactoring changes at the same
-    time, it helps reviewers understand how your feature changes
-    the current functionality.
-  * Separate logical features into separate changes.  This
-    is often the hardest part.  Here is an example:  when adding a
-    new ability, make separate changes for the UI and the ssh
-    commands if possible.
-  * Do only what the commit message describes.  In other words, things which
-    are not strictly related to the commit message shouldn't be part of
-    a change, even trivial things like externalizing a string somewhere
-    or fixing a typo.  This helps keep `git blame` more useful in the future
-    and it also makes `git revert` more useful.
-  * Use topics to link your separate changes together.
-
-[[process]]
-== Process
-
-[[dev-in-stable-branches]]
-=== Development in stable branches
-
-As their name suggests stable branches are intended to be stable. This means that generally
-only bug-fixes should be done on stable branches, however this is not strictly enforced and
-exceptions may apply:
-
-  * When a stable branch is initially created to prepare a new release the Gerrit community
-    discusses on the mailing list if there are pending features which should still make it into the
-    release. Those features are blocking the release and should be implemented on the stable
-    branch before the first release candidate is created.
-  * To stabilize the code before doing a major release several release candidates are created. Once
-    the first release candidate was done no more features should be accepted on the stable branch.
-    If more features are found to be required they should be discussed with the Gerrit maintainers
-    and should only be allowed if the risk of breaking things is considered to be low.
-  * Once a major release is done only bug-fixes and documentation updates should be done on the
-    stable branch. These updates will be included in the next minor release.
-  * For minor releases new features are only acceptable if they are important to the Gerrit
-    community, if they are backwards compatible and the risk of breaking things is low and if there
-    are no objections from the Gerrit community.
-  * In cases of doubt it's the responsibility of the release maintainer to evaluate the risk of new
-    features and make a decision based on these rules and opinions from the Gerrit community.
-  * The older a stable branch is the more stable it should be. This means old stable branches
-    should only receive bug-fixes that are either important or low risk. Security fixes, including
-    security updates for third party dependencies, are always considered as important and hence can
-    always be done on stable branches.
-
-=== Backporting to stable branches
-
-From time to time bug fix releases are made for existing stable branches.
-
-Developers concerned with stable branches are encouraged to backport or push fixes to these
-branches, even if no new release is planned. Backporting features is only possible in compliance
-with the rules link:#dev-in-stable-branches[above].
-
-Fixes that are known to be needed for a particular release should be pushed for review on that
-release's stable branch. They will then be included into the master branch when the stable branch
-is merged back.
-
-=== Finding starter projects to work on
-
-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
-link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
-
-=== Upgrading Libraries
-
-Gerrit's library dependencies should only be upgraded if the new version contains
-something we need in Gerrit. This includes new features, API changes as well as bug
-or security fixes.
-An exception to this rule is that right after a new Gerrit release was branched
-off, all libraries should be upgraded to the latest version to prevent Gerrit
-from falling behind. Doing those upgrades should conclude at the latest two
-months after the branch was cut. This should happen on the master branch to ensure
-that they are vetted long enough before they go into a release and we can be sure
-that the update doesn't introduce a regression.
-
-[[deprecating-features]]
-=== Deprecating features
-
-Gerrit should be as stable as possible and we aim to add only features that last.
-However, sometimes we are required to deprecate and remove features to be able
-to move forward with the project and keep the code-base clean. The following process
-should serve as a guideline on how to deprecate functionality in Gerrit. Its purpose
-is that we have a structured process for deprecation that users, administrators and
-developers can agree and rely on.
-
-General process:
-
-  * Make sure that the feature (e.g. a field on the API) is not needed anymore or blocks
-    further development or improvement. If in doubt, consult the mailing list.
-  * If you can provide a schema migration that moves users to a comparable feature, do
-    so and stop here.
-  * Mark the feature as deprecated in the documentation and release notes.
-  * If possible, mark the feature deprecated in any user-visible interface. For example,
-    if you are deprecating a Git push option, add a message to the Git response if
-    the user provided the option informing them about deprecation.
-  * Annotate the code with `@Deprecated` and `@RemoveAfter(x.xx)` if applicable.
-    Alternatively, use `// DEPRECATED, remove after x.xx` (where x.xx is the version
-    number that has to be branched off before removing the feature)
-  * Gate the feature behind a config that is off by default (forcing admins to turn
-    the deprecated feature on explicitly).
-  * After the next release was branched off, remove any code that backed the feature.
-
-You can optionally consult the mailing list to ask if there are users of the feature you
-wish to deprecate. If there are no major users, you can remove the feature without
-following this process and without the grace period of one release.
+If a feature is not finished in time, it should be discussed with the
+steering committee how to proceed. If the contributor cannot commit to
+finish the implementation in time and no other contributor can take
+over, changes that have already been submitted for the feature might
+get reverted so that there is no unused or half-finished code in the
+code base.
 
 GERRIT
 ------
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
new file mode 100644
index 0000000..0f93be7
--- /dev/null
+++ b/Documentation/dev-crafting-changes.txt
@@ -0,0 +1,271 @@
+= Gerrit Code Review - Crafting Changes
+
+Here are some hints as to what approvers may be looking for
+before approving or submitting changes to the Gerrit project.
+Let's start with the simple nit picky stuff.  You are likely
+excited that your code works; help us share your excitement
+by not distracting us with the simple stuff.  Thanks to Gerrit,
+problems are often highlighted and we find it hard to look
+beyond simple spacing issues.  Blame it on our short attention
+spans, we really do want your code.
+
+[[commit-message]]
+== Commit Message
+
+It is essential to have a good commit message if you want your
+change to be reviewed.
+
+  * Keep lines no longer than 72 chars
+  * Start with a short one line summary
+  * Followed by a blank line
+  * Followed by one or more explanatory paragraphs
+  * Use the present tense (fix instead of fixed)
+  * Use the past tense when describing the status before this commit
+  * Include a `Bug: Issue <#>` line if fixing a Gerrit issue, or a
+    `Feature: Issue <#>` line if implementing a feature request.
+  * Include a `Change-Id` line
+
+[[vim-setup]]
+=== Setting up Vim for Git commit message
+
+Git uses Vim as the default commit message editor. Put this into your
+`$HOME/.vimrc` file to configure Vim for Git commit message formatting
+and writing:
+
+====
+  " Enable spell checking, which is not on by default for commit messages.
+  au FileType gitcommit setlocal spell
+
+  " Reset textwidth if you've previously overridden it.
+  au FileType gitcommit setlocal textwidth=72
+====
+
+
+[[git-commit-settings]]
+=== A sample good Gerrit commit message:
+====
+  Add sample commit message to guidelines doc
+
+  The original patch set for the contributing guidelines doc did not
+  include a sample commit message, this new patchset does.  Hopefully this
+  makes things a bit clearer since examples can sometimes help when
+  explanations don't.
+
+  Note that the body of this commit message can be several paragraphs, and
+  that I word wrap it at 72 characters.  Also note that I keep the summary
+  line under 50 characters since it is often truncated by tools which
+  display just the git summary.
+
+  Bug: Issue 98765605
+  Change-Id: Ic4a7c07eeb98cdeaf44e9d231a65a51f3fceae52
+====
+
+The `Change-Id` line is, as usual, created by a local git hook.  To install it,
+simply copy it from the checkout and make it executable:
+
+====
+  cp ./gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg .git/hooks/
+  chmod +x .git/hooks/commit-msg
+====
+
+If you are working on core plugins, you will also need to install the
+same hook in the submodules:
+
+====
+  export hook=$(pwd)/.git/hooks/commit-msg
+  git submodule foreach 'cp -p "$hook" "$(git rev-parse --git-dir)/hooks/"'
+====
+
+
+To set up git's remote for easy pushing, run the following:
+
+====
+  git remote add gerrit https://gerrit.googlesource.com/gerrit
+====
+
+The HTTPS access requires proper username and password; this can be obtained
+by clicking the 'Obtain Password' link on the
+link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
+Password tab of the user settings page].
+
+[[style]]
+== Style
+
+This project has a policy of Eclipse's warning free code. Eclipse
+configuration is added to git and we expect the changes to be
+warnings free.
+
+We do not ask you to use Eclipse for editing, obviously.  We do ask you
+to provide Eclipse's warning free patches only. If for some reasons, you
+are not able to set up Eclipse and verify, that your patch hasn't
+introduced any new Eclipse warnings, mention this in a comment to your
+change, so that reviewers will do it for you. Yes, the way to go is to
+extend gerrit CI to take care of this, but it's not yet implemented.
+
+Gerrit generally follows the
+link:https://google.github.io/styleguide/javaguide.html[Google Java Style
+Guide].
+
+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.22.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.
+
+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
+stick to the blank line conventions.  Here are some specific
+examples:
+
+  * Keep a blank line between all class and method declarations.
+  * Do not add blank lines at the beginning or end of class/methods.
+
+When to use `final` modifier and when not (in new code):
+
+Always:
+
+  * final fields: marking fields as final forces them to be
+  initialized in the constructor or at declaration
+  * final static fields: clearly communicates the intent
+  * to use final variables in inner anonymous classes
+
+Optional:
+
+  * final classes: use when appropriate, e.g. API restriction
+  * final methods: similar to final classes
+
+Never:
+
+  * 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
+
+[[code-organization]]
+== Code Organization
+
+Do your best to organize classes and methods in a logical way.
+Here are some guidelines that Gerrit uses:
+
+  * Ensure a standard copyright header is included at the top
+    of any new files (copy it from another file, update the year).
+  * Always place loggers first in your class!
+  * Define any static interfaces next in your class.
+  * Define non static interfaces after static interfaces in your
+    class.
+  * 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).
+  * Getters and setters for the same instance field should usually
+    be near each other barring a good reason not to.
+  * If you are using assisted injection, the factory for your class
+    should be before the instance members.
+  * Annotations should go before language keywords (`final`, `private`, etc) +
+    Example: `@Assisted @Nullable final type varName`
+  * 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.
+
+Wow that's a lot!  But don't worry, you'll get the habit and most
+of the code is organized this way already; so if you pay attention
+to the class you are editing you will likely pick up on it.
+Naturally new classes are a little harder; you may want to come
+back and consult this section when creating them.
+
+[[design]]
+== Design
+
+Here are some design level objectives that you should keep in mind
+when coding:
+
+  * Most client pages should perform only one RPC to load so as to
+    keep latencies down.  Exceptions would apply to RPCs which need
+    to load large data sets if splitting them out will help the
+    page load faster.  Generally page loads are expected to complete
+    in under 100ms.  This will be the case for most operations,
+    unless the data being fetched is not using Gerrit's caching
+    infrastructure.  In these slower cases, it is worth considering
+    mitigating this longer load by using a second RPC to fill in
+    this data after the page is displayed (or alternatively it might
+    be worth proposing caching this data).
+  * `@Inject` should be used on constructors, not on fields.  The
+    current exceptions are the ssh commands, these were implemented
+    earlier in Gerrit's development.  To stay consistent, new ssh
+    commands should follow this older pattern; but eventually these
+    should get converted to eliminate this exception.
+  * Don't leave repository objects (git or schema) open.  A .close()
+    after every open should be placed in a finally{} block.
+  * Don't leave UI components, which can cause new actions to occur,
+    enabled during RPCs which update Git repositories, including NoteDb.
+    This is to prevent people from submitting actions more than once
+    when operating on slow links.  If the action buttons are disabled,
+    they cannot be resubmitted and the user can see that Gerrit is still
+    busy.
+  * ...and so is Guava (previously known as Google Collections).
+
+[[tests]]
+== Tests
+
+  * Tests for new code will greatly help your change get approved.
+
+[[change-size]]
+== Change Size/Number of Files Touched
+
+And finally, I probably cannot say enough about change sizes.
+Generally, smaller is better, hopefully within reason.  Do try to
+keep things which will be confusing on their own together,
+especially if changing one without the other will break something!
+
+  * If a new feature is implemented and it is a larger one, try to
+    identify if it can be split into smaller logical features; when
+    in doubt, err on the smaller side.
+  * Separate bug fixes from feature improvements.  The bug fix may
+    be an easy candidate for approval and should not need to wait
+    for new features to be approved.  Also, combining the two makes
+    reviewing harder since then there is no clear line between the
+    fix and the feature.
+  * Separate supporting refactoring from feature changes.  If your
+    new feature requires some refactoring, it helps to make the
+    refactoring a separate change which your feature change
+    depends on.  This way, reviewers can easily review the refactor
+    change as a something that should not alter the current
+    functionality, and feel more confident they can more easily
+    spot errors this way.  Of course, it also makes it easier to
+    test and locate later on if an unfortunate error does slip in.
+    Lastly, by not having to see refactoring changes at the same
+    time, it helps reviewers understand how your feature changes
+    the current functionality.
+  * Separate logical features into separate changes.  This
+    is often the hardest part.  Here is an example:  when adding a
+    new ability, make separate changes for the UI and the ssh
+    commands if possible.
+  * Do only what the commit message describes.  In other words, things which
+    are not strictly related to the commit message shouldn't be part of
+    a change, even trivial things like externalizing a string somewhere
+    or fixing a typo.  This helps keep `git blame` more useful in the future
+    and it also makes `git revert` more useful.
+  * Use topics to link your separate changes together.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design-doc-template.txt b/Documentation/dev-design-doc-template.txt
new file mode 100644
index 0000000..9480d97
--- /dev/null
+++ b/Documentation/dev-design-doc-template.txt
@@ -0,0 +1,79 @@
+= Gerrit Code Review - ${title}
+
+[[objective]]
+== Objective
+
+In a few sentences, describe the key system objectives. Define the
+goals and non-goals.
+
+[[background]]
+== Background
+
+Stuff one needs to know to understand this doc (e.g. motivating
+examples, previous versions and problems, links to related
+changes/design docs, etc.
+
+Note: this is background; do not write about your design or ideas to
+solve problems here.
+
+[[overview]]
+== Overview
+
+High-level overview; put details in the next section and background in
+the previous section. Should be understandable by engineers that are
+not working on Gerrit.
+
+[[detailed-design]]
+== Detailed Design
+
+How does the overall design work? Details about the algorithms,
+storage format, APIs, etc., should be included here.
+
+It is ok for this to lack in detail at first for initial review.
+
+[[alternatives-considered]]
+== Alternatives Considered
+
+You may need to describe what you did not do or why simpler approaches
+don't work. Mention other things to watch out for (if any).
+
+[[implemenation-plan]]
+== Implementation Plan
+
+If known, say who is driving the implementation, for when the
+implementation is planned and which priority it has for you.
+
+It is possible to contribute designs without having resources to do the
+implementation. In this case, say so here.
+
+If mentor support is desired, say so here. Also briefly describe any
+circumstances that can help with finding a suitable mentor.
+
+[[time-estimation]]
+=== Time Estimation
+
+A rough itemized estimation of how much time it takes to implement this
+feature. Break down the feature into work items and estimate each item
+separately.
+
+If a mentor is assigned, this section must define a maximum time frame
+after which the mentorship automatically ends even if the feature isn't
+fully done yet.
+
+[[done-criteria]]
+== Done Criteria
+
+Describe the conditions that must be satisfied to consider this feature
+as done.
+
+If a mentor is assigned, the mentorship ends when this state is reached.
+Please note that a mentorship can also end earlier if the maximum time
+frame for the mentorship has exceeded (see section 'Time Estimation'
+above).
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
new file mode 100644
index 0000000..8cc7e81
--- /dev/null
+++ b/Documentation/dev-design-docs.txt
@@ -0,0 +1,62 @@
+= Gerrit Code Review - Design Docs
+
+For the link:dev-contributing.html#design-driven-contribution-process[
+design-driven contribution process] it is required to specify features
+upfront in a design doc.
+
+[[propose]]
+== How to propose a new design?
+
+To propose a new design, add a `design-${title}.txt` file to this
+folder and push it as change for review. The design doc should follow
+the structure of the link:dev-design-doc-template.html[design doc
+template] and the change should be marked with the hashtag
+`design-doc`.
+
+Pushing a design doc for review requires to be a
+link:dev-roles.html#contributor[contributor].
+
+When contributing design docs, contributors should make clear whether
+they are committed to do the implementation. It is possible to
+contribute designs without having resources to do the implementation,
+but in this case the implementation is only done if someone volunteers
+to do it (which is not guaranteed to happen).
+
+[[review]]
+== Design doc review
+
+Everyone in the link:dev-roles.html[Gerrit community] is welcome to
+take part in the design review and comment on the design.
+
+Changes with new design docs should stay open for a minimum of 10
+calendar days so that everyone has a fair chance to see them. It is
+important that concerns regarding a feature are raised during this time
+frame since once a design is approved and submitted the implementation
+may start immediately.
+
+Within the 10 calendar days time frame, the contributor should hear back
+from the link:dev-processes.html#steering-committee[steering committee]
+whether the proposed feature is in scope of the project and if it can
+be accepted.
+
+In order to be accepted/submitted, it is not necessary that the design
+doc fully specifies all the details, but the idea of the feature and
+how it fits into Gerrit should be sufficiently clear (judged by the
+steering committee). Contributors are expected to keep the design doc
+updated and fill in gaps while they go forward with the implementation.
+
+[[watch-designs]]
+== How to get notified for new design docs?
+
+. Go to the
+  link:https://gerrit-review.googlesource.com/settings/#Notifications[
+  notification settings]
+. Add a project watch for the `gerrit` repository with the following
+  query: `hashtag:design-doc`
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index 69af18d..1285404 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -178,17 +178,6 @@
 repositories for each project.
 
 
-== Project Information
-
-Gerrit is developed as a self-hosting open source project:
-
-* link:https://www.gerritcodereview.com/[Project Homepage]
-* link:https://www.gerritcodereview.com/download/index.html[Release Versions]
-* link:https://gerrit.googlesource.com/gerrit[Source]
-* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
-* link:https://review.source.android.com/[Change Review]
-
-
 == Internationalization and Localization
 
 As a source code review system for open source projects, where the
@@ -204,8 +193,6 @@
 RTL into consideration, while others probably need to be modified
 before translating the UI to an RTL language.
 
-* link:i18n-readme.html[Gerrit's i18n Support]
-
 
 == Accessibility Considerations
 
@@ -640,29 +627,6 @@
 scope of Gerrit.
 
 
-== Testing Plan
-
-Gerrit is currently manually tested through its web UI.
-
-JGit has a fairly extensive automated unit test suite.  Most new
-changes to JGit are rejected unless corresponding automated unit
-tests are included.
-
-
-== Caveats
-
-Rietveld can't be used as it does not provide the "submit over the
-web" feature that Gerrit provides for Git.
-
-Gitosis can't be used as it does not provide any code review
-features, but it does provide basic access controls.
-
-Email based code review does not scale to a project as large and
-complex as Android.  Most contributors at least need some sort of
-dashboard to keep track of any pending reviews, and some way to
-correlate updated revisions back to the comments written on prior
-revisions of the same logical change.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
new file mode 100644
index 0000000..3f98ce7
--- /dev/null
+++ b/Documentation/dev-processes.txt
@@ -0,0 +1,177 @@
+= Gerrit Code Review - Development Processes
+
+[[project-governance]]
+[[steering-committee]]
+== Project Governance / Steering Committee
+
+The Gerrit project has a steering committee that is in charge of:
+
+* Gerrit core (the `gerrit` project) and the core plugins
+* defining the project vision and the project scope
+* maintaining a roadmap, a release plan and a prioritized backlog
+* ensuring timely design reviews
+* ensuring that new features are compatible with the project vision and
+  are well aligned with other features (give feedback on new
+  link:dev-design-docs.html[design docs] within 14 calendar days)
+* approving/rejecting link:dev-design-docs.html[designs], vetoing new
+  features
+* assigning link:dev-roles.html#mentor[mentors] for approved features
+* accepting new plugins as core plugins
+* making changes to the project governance process and the
+  link:dev-contributing.html#contribution-processes[contribution
+  processes]
+
+The steering committee has 5 members:
+
+* 3 Googlers that are appointed by Google
+* 2 non-Google maintainers, elected by non-Google maintainers for the
+  period of 1 year (see link:#steering-committee-election[below])
+
+The steering committee should act in the interest of the Gerrit project
+and the whole Gerrit community.
+
+For decisions, consensus between steering committee members and all
+other maintainers is desired. If consensus cannot be reached, decisions
+can also be made by simple majority in the steering committee (should
+be applied only in exceptional situations).
+
+The steering committee is empowered to overrule positive/negative votes
+from individual maintainers, but should do so only in exceptional
+situations after attempts to reach consensus have failed.
+
+As an integral part of the Gerrit community, the steering committee is
+committed to transparency and to answering incoming requests in a
+timely manner.
+
+[[steering-committee-election]]
+=== Election of non-Google steering committee members
+
+The election of the non-Google steering committee members happens once
+a year in May. Non-Google link:dev-roles.html#maintainer[maintainers]
+can nominate themselves by posting an informal application on the
+non-public maintainers mailing list by end of April (deadline for 2019
+is Mon 13th of May). By applying to be steering committee member, the
+candidate confirms to be able to dedicate the time that is needed to
+fulfill this role (also see
+link:dev-roles.html#steering-committee-member[steering committee
+member]).
+
+Each non-Google maintainer can vote for 2 candidates. The voting
+happens by posting on the maintainer mailing list. The voting period is
+14 calendar days from the nomination deadline (except for 2019, where
+the initial steering committee should be confirmed during the Munich
+hackathon, the voting period goes from 14th May to 16th May).
+
+Google maintainers do not take part in this vote, because Google
+already has dedicated seats in the steering committee (see section
+link:steering-committee[steering committee]).
+
+[[contribution-process]]
+== Contribution Process
+
+See link:dev-contributing.html[here].
+
+[[design-doc-review]]
+== Design Doc Review
+
+See link:dev-design-docs.html#review[here].
+
+[[dev-in-stable-branches]]
+== Development in stable branches
+
+As their name suggests stable branches are intended to be stable. This means that generally
+only bug-fixes should be done on stable branches, however this is not strictly enforced and
+exceptions may apply:
+
+  * When a stable branch is initially created to prepare a new release the Gerrit community
+    discusses on the mailing list if there are pending features which should still make it into the
+    release. Those features are blocking the release and should be implemented on the stable
+    branch before the first release candidate is created.
+  * To stabilize the code before doing a major release several release candidates are created. Once
+    the first release candidate was done no more features should be accepted on the stable branch.
+    If more features are found to be required they should be discussed with the steering committee
+    and should only be allowed if the risk of breaking things is considered to be low.
+  * Once a major release is done only bug-fixes and documentation updates should be done on the
+    stable branch. These updates will be included in the next minor release.
+  * For minor releases new features are only acceptable if they are important to the Gerrit
+    community, if they are backwards compatible and the risk of breaking things is low and if there
+    are no objections from the steering committee.
+  * In cases of doubt it's the responsibility of the steering committee to evaluate the risk of new
+    features and make a decision based on these rules and opinions from the Gerrit community.
+  * The older a stable branch is the more stable it should be. This means old stable branches
+    should only receive bug-fixes that are either important or low risk. Security fixes, including
+    security updates for third party dependencies, are always considered as important and hence can
+    always be done on stable branches.
+
+[[backporting]]
+== Backporting to stable branches
+
+From time to time bug fix releases are made for existing stable branches.
+
+Developers concerned with stable branches are encouraged to backport or push fixes to these
+branches, even if no new release is planned. Backporting features is only possible in compliance
+with the rules link:#dev-in-stable-branches[above].
+
+Fixes that are known to be needed for a particular release should be pushed for review on that
+release's stable branch. They will then be included into the master branch when the stable branch
+is merged back.
+
+[[upgrading-libraries]]
+== Upgrading Libraries
+
+Changes that add new libraries or upgrade existing libraries require an approval on the
+`Library-Compliance` label. For an approval the following things are checked:
+
+* The library has a license that is suitable for use within Gerrit.
+* If the library is used within Google, the version of the library must be compatible with the
+  version that is used at Google.
+
+Only maintainers from Google can vote on the `Library-Compliance` label.
+
+Gerrit's library dependencies should only be upgraded if the new version contains
+something we need in Gerrit. This includes new features, API changes as well as bug
+or security fixes.
+An exception to this rule is that right after a new Gerrit release was branched
+off, all libraries should be upgraded to the latest version to prevent Gerrit
+from falling behind. Doing those upgrades should conclude at the latest two
+months after the branch was cut. This should happen on the master branch to ensure
+that they are vetted long enough before they go into a release and we can be sure
+that the update doesn't introduce a regression.
+
+[[deprecating-features]]
+== Deprecating features
+
+Gerrit should be as stable as possible and we aim to add only features that last.
+However, sometimes we are required to deprecate and remove features to be able
+to move forward with the project and keep the code-base clean. The following process
+should serve as a guideline on how to deprecate functionality in Gerrit. Its purpose
+is that we have a structured process for deprecation that users, administrators and
+developers can agree and rely on.
+
+General process:
+
+  * Make sure that the feature (e.g. a field on the API) is not needed anymore or blocks
+    further development or improvement. If in doubt, consult the mailing list.
+  * If you can provide a schema migration that moves users to a comparable feature, do
+    so and stop here.
+  * Mark the feature as deprecated in the documentation and release notes.
+  * If possible, mark the feature deprecated in any user-visible interface. For example,
+    if you are deprecating a Git push option, add a message to the Git response if
+    the user provided the option informing them about deprecation.
+  * Annotate the code with `@Deprecated` and `@RemoveAfter(x.xx)` if applicable.
+    Alternatively, use `// DEPRECATED, remove after x.xx` (where x.xx is the version
+    number that has to be branched off before removing the feature)
+  * Gate the feature behind a config that is off by default (forcing admins to turn
+    the deprecated feature on explicitly).
+  * After the next release was branched off, remove any code that backed the feature.
+
+You can optionally consult the mailing list to ask if there are users of the feature you
+wish to deprecate. If there are no major users, you can remove the feature without
+following this process and without the grace period of one release.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 8bf4814..02b1891 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -3,7 +3,9 @@
 To build a developer instance, you'll need link:https://bazel.build/[Bazel] to
 compile the code.
 
-== Getting the Source
+== Git Setup
+
+=== Getting the Source
 
 Create a new client workspace:
 
@@ -12,102 +14,36 @@
   cd gerrit
 ----
 
-The `--recursive` option is needed on `git clone` to ensure that
-the core plugins, which are included as git submodules, are also
-cloned.
+The `--recurse-submodules` option is needed on `git clone` to ensure that the
+core plugins, which are included as git submodules, are also cloned.
+
+=== Switching between branches
+
+When using `git checkout` without `--recurse-submodules` to switch between
+branches, submodule revisions are not altered, which can result in:
+
+*  Incorrect or unneeded plugin revisions.
+*  Missing plugins.
+
+After you switch branches, ensure that you have the correct versions of
+the submodules.
+
+CAUTION: If you store Eclipse or IntelliJ project files in the Gerrit source
+directories, do *_not_* run `git clean -fdx`. Doing so may remove untracked files and damage your project. For more information, see
+link:https://git-scm.com/docs/git-clean[git-clean].
+
+Run the following:
+
+----
+  git submodule update
+  git clean -ffd
+----
 
 [[compile_project]]
 == Compiling
 
 For details, see <<dev-bazel#,Building with Bazel>>.
 
-== Configuring Eclipse
-
-To use the Eclipse IDE for development, see
-link:dev-eclipse.html[Eclipse Setup].
-
-To configure the Eclipse workspace with Bazel, see
-link:dev-bazel.html#eclipse[Eclipse integration with Bazel].
-
-== Configuring IntelliJ IDEA
-
-See <<dev-intellij#,IntelliJ Setup>> for details.
-
-== MacOS
-
-On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
-and that `JAVA_HOME` is set to the
-link:install.html#Requirements[required Java version].
-
-Java installations can typically be found in
-"/System/Library/Frameworks/JavaVM.framework/Versions".
-
-To check the installed version of Java, open a terminal window and run:
-
-`java -version`
-
-[[init]]
-== Site Initialization
-
-After you compile the project <<compile_project,(above)>>, run the Gerrit
-`init`
-command to create a test site:
-
-----
-  $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war init -d ../gerrit_testsite
-----
-
-[[special_bazel_java_version]]
-NOTE: You must use the same Java version that Bazel used for the build, which
-is available at `$(bazel info output_base)/external/local_jdk/bin/java`.
-
-During initialization, change two settings from the defaults:
-
-*  To ensure the development instance is not externally accessible, change the
-listen addresses from '*' to 'localhost'.
-*  To allow yourself to create and act as arbitrary test accounts on your
-development instance, change the auth type from 'OPENID' to 'DEVELOPMENT_BECOME_ANY_ACCOUNT'.
-
-After initializing the test site, Gerrit starts serving in the background. A
-web browser displays the Start page.
-
-On the Start page, you can:
-
-.  Log in as the account you created during the initialization process.
-.  Register additional accounts.
-.  Create projects.
-
-To shut down the daemon, run:
-
-----
-  ../gerrit_testsite/bin/gerrit.sh stop
-----
-
-
-[[localdev]]
-== Working with the Local Server
-
-To create more accounts on your development instance:
-
-.  Click 'become' in the upper right corner.
-.  Select 'Switch User'.
-.  Register a new account.
-.  link:user-upload.html#ssh[Configure your SSH key].
-
-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
-----
-
-To create changes as users of Gerrit would, run:
-
-----
-git push origin HEAD:refs/for/master
-----
 
 == Testing
 
@@ -124,6 +60,92 @@
 For instructions on running the acceptance tests with Bazel,
 see <<dev-bazel#tests,Running Unit Tests with Bazel>>.
 
+
+== Local server
+
+[[init]]
+=== Site Initialization
+
+After you compile the project <<compile_project,(above)>>, run the Gerrit
+`init`
+command to create a test site:
+
+----
+  export GERRIT_SITE=~/gerrit_testsite
+  $(bazel info output_base)/external/local_jdk/bin/java \
+      -jar bazel-bin/gerrit.war init --batch --dev -d $GERRIT_SITE
+----
+
+[[special_bazel_java_version]]
+NOTE: You must use the same Java version that Bazel used for the build, which
+is available at `$(bazel info output_base)/external/local_jdk/bin/java`.
+
+This command takes two parameters:
+
+* `--batch` assigns default values to several Gerrit configuration
+    options. To learn more about these options, see
+    link:config-gerrit.html[Configuration].
+* `--dev` configures the Gerrit server to use the authentication
+  option, `DEVELOPMENT_BECOME_ANY_ACCOUNT`, which enables you to
+  switch between different users to explore how Gerrit works. To learn more
+  about setting up Gerrit for development, see
+  link:dev-readme.html[Gerrit Code Review: Developer Setup].
+
+After initializing the test site, Gerrit starts serving in the background. A
+web browser displays the Start page.
+
+On the Start page, you can:
+
+.  Log in as the account you created during the initialization process.
+.  Register additional accounts.
+.  Create projects.
+
+To shut down the daemon, run:
+
+----
+  $GERRIT_SITE/bin/gerrit.sh stop
+----
+
+
+[[localdev]]
+=== Working with the Local Server
+
+To create more accounts on your development instance:
+
+.  Click 'become' in the upper right corner.
+.  Select 'Switch User'.
+.  Register a new account.
+.  link:user-upload.html#ssh[Configure your SSH key].
+
+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
+----
+
+To use the `HTTP` protocol, run:
+
+----
+git clone http://username@localhost:8080/projectname
+----
+
+The default password for user `admin` is `secret`. You can regenerate a
+password in the UI under User Settings -- HTTP credentials. The password can be
+stored locally to avoid retyping it:
+
+----
+git config --global credential.helper store
+git pull
+----
+
+To create changes as users of Gerrit would, run:
+
+----
+git push origin HEAD:refs/for/master
+----
+
 [[run_daemon]]
 === Running the Daemon
 
@@ -132,7 +154,7 @@
 
 ----
   $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite \
+     -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE \
      --console-log
 ----
 
@@ -164,7 +186,7 @@
 
 ----
   $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite -s
+     -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE -s
 ----
 
 NOTE: To learn why using `java -jar` isn't sufficient, see
@@ -188,27 +210,24 @@
 CAUTION: When using the Inspector, be careful not to modify the internal state
 of the system.
 
-== Switching between branches
 
-When using `git checkout` without `--recurse-submodules` to switch between
-branches, submodule revisions are not altered, which can result in:
+== Setup for backend developers
 
-*  Incorrect or unneeded plugin revisions.
-*  Missing plugins.
+=== Configuring Eclipse
 
-After you switch branches, ensure that you have the correct versions of
-the submodules.
+To use the Eclipse IDE for development, see
+link:dev-eclipse.html[Eclipse Setup].
 
-CAUTION: If you store Eclipse or IntelliJ project files in the Gerrit source
-directories, do *_not_* run `git clean -fdx`. Doing so may remove untracked files and damage your project. For more information, see
-link:https://git-scm.com/docs/git-clean[git-clean].
+To configure the Eclipse workspace with Bazel, see
+link:dev-bazel.html#eclipse[Eclipse integration with Bazel].
 
-Run the following:
+=== Configuring IntelliJ IDEA
 
-----
-  git submodule update
-  git clean -ffd
-----
+See <<dev-intellij#,IntelliJ Setup>> for details.
+
+== Setup for frontend developers
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md[Frontend Developer Setup].
+
 
 GERRIT
 ------
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index 5411927..98a3df5 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -91,7 +91,7 @@
 
 * `gerrit-maven`:
 +
-Bucket to store Gerrit Subproject Artifacts (e.g. `gwtorm` etc.).
+Bucket to store Gerrit Subproject Artifacts (e.g. Prolog Cafe).
 
 To upload artifacts to a bucket the user must authenticate with a
 username and password. The username and password need to be retrieved
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
new file mode 100644
index 0000000..988e20cf
--- /dev/null
+++ b/Documentation/dev-roles.txt
@@ -0,0 +1,337 @@
+= Gerrit Code Review - Supporting Roles
+
+As an open source project Gerrit has a large community of people that
+are driving the project forward and there are many ways to engage with
+the project and get involved.
+
+[[supporter]]
+== Supporter
+
+Supporters are individuals who help the Gerrit project and the Gerrit
+community in any way. This includes users that provide feedback to the
+Gerrit community or get in touch by other means.
+
+There are many possibilities to support the project, e.g.:
+
+* get involved in discussions on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list (post your questions, provide feedback, share your
+  experiences, help other users)
+* attend community events like user summits (see
+  link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+  community calendar])
+* report link:https://bugs.chromium.org/p/gerrit/issues/list[issues]
+  and help to clarify existing issues
+* provide feedback on
+  link:https://www.gerritcodereview.com/releases-readme.html[new
+  releases and release candidates]
+* review
+  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  and help to verify that they work as advertised, comment if you like
+  or dislike a feature
+* serve as contact person for a proprietary Gerrit installation and
+  channel feedback from users back to the Gerrit community
+
+Supporters can:
+
+* post on the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list (Please note that the `repo-discuss` mailing list is
+  managed to prevent spam posts. This means posts from new participants
+  must be approved manually before they appear on the mailing list.
+  Approvals normally happen within 1 work day. Posts of people who
+  participate in mailing list discussions frequently are approved
+  automatically)
+* comment on
+  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  and vote from `-1` to `+1` on the `Code-Review` label (these votes
+  are important to understand the interest in a change and to address
+  concerns early, however link:#maintainer[maintainers] can
+  overrule/ignore these votes)
+* download changes to try them out, feedback can be provided as
+  comments and by voting (preferably on the `Verified` label,
+  permissions to vote on the `Verified` label are granted by request,
+  see below)
+* file issues in the link:https://bugs.chromium.org/p/gerrit/issues/list[
+  issue tracker] and comment on existing issues
+* support the
+  link:dev-processes.html#design-driven-contribution-process[
+  design-driven contribution process] by reviewing incoming
+  link:dev-design-docs.html[design docs] and raising concerns during
+  the design review
+
+Supporters who want to engage further can get additional privileges
+on request (ask for it on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list):
+
+* become member of the `gerrit-verifiers` group, which allows to:
+** vote on the `Verified` and `Code-Style` labels
+** edit hashtags on all changes
+** edit topics on all open changes
+** abandon changes
+* approve posts to the
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  mailing list
+* administrate issues in the
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
+
+Supporters can become link:#contributor[contributors] by signing a
+contributor license agreement and contributing code to the Gerrit
+project.
+
+[[contributor]]
+== Contributor
+
+Everyone who has a valid link:dev-cla.html[contributor license
+agreement] and who has link:dev-contributing.html[contributed] at least
+one change to any project on
+link:https://gerrit-review.googlesource.com/[
+gerrit-review.googlesource.com] is a contributor.
+
+Contributions can be:
+
+* new features
+* bug fixes
+* code cleanups
+* documentation updates
+* release notes updates
+* propose link:#dev-design-docs[design docs] as part of the
+  link:dev-contributing.html#design-driven-contribution-process[
+  design-driven contribution process]
+* scripts which are of interest to the community
+
+Contributors have all the permissions that link:#supporter[supporters]
+have. In addition they have signed a link:dev-cla.html[contributor
+license agreement] which enables them to push changes.
+
+Regular contributors can ask to be added to the `gerrit-verifiers`
+group, which allows to:
+
+* add patch sets to changes of other users
+* propose project config changes (push changes for the
+  `refs/meta/config` branch
+
+Being member of the `gerrit-verifiers` group includes further
+permissions (see link:#supporter[supporter] section above).
+
+It's highly appreciated if contributors engage in code reviews,
+link:dev-design-docs.html#review[design reviews] and mailing list
+discussions. If wanted, contributors can also serve as link#mentor[
+mentors] to support other contributors with getting their features
+done.
+
+Contributors may also be invited to join the Gerrit hackathons which
+happen regularly (e.g. twice a year). Hackathons are announced on the
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+mailing list (also see
+link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+community calendar]).
+
+Outstanding contributors that are actively engaged in the community, in
+activities outlined above, may be nominated as link:#maintainer[
+maintainers].
+
+[[maintainer]]
+== Maintainer
+
+Maintainers are the gatekeepers of the project and are in charge of
+approving and submitting changes.
+
+Maintainers should only approve changes that:
+
+* they fully understand
+* are in line with the project vision and project scope that are
+  defined by the link:dev-processes.html#steering-committee[steering
+  committee], and should consult them, when in doubt
+* meet the quality expectations of the project (well-tested, properly
+  documented, scalable, backwards-compatible)
+* implement usable features or bug fixes (no incomplete/unusable
+  things)
+* are not authored by themselves (exceptions are changes which are
+  trivial according to the judgment of the maintainer and changes that
+  are required by the release process and branch management)
+
+Maintainers are trusted to assess changes, but are also expected to
+align with the other maintainers, especially if large new features are
+being added.
+
+Maintainers are highly encouraged to dedicate some of their time to the
+following tasks (but are not required to do so):
+
+* reviewing changes
+* mailing list discussions and support
+* bug fixing and bug triaging
+* supporting the
+  link:dev-processes.html#design-driven-contribution-process[
+  design-driven contribution process] by reviewing incoming
+  link:dev-design-docs.html[design docs] and raising concerns during
+  the design review
+* serving as link:#mentor[mentor]
+* doing releases (see link#release-manager[release manager])
+
+Maintainers can:
+
+* approve changes (vote `+2` on the `Code-Review` label); when
+  approving changes, `-1` votes on the `Code-Review` label can be
+  ignored if there is a good reason, in this case the reason should be
+  clearly communicated on the change
+* submit changes
+* block submission of changes if they disagree with how a feature is
+  being implemented (vote `-2` on the `Code-Review` label), but their
+  vote can be overruled by the steering committee, see
+  link:dev-processes.html#project-governance[Project Governance]
+* nominate new maintainers and vote on nominations (see below)
+* administrate the link:https://groups.google.com/d/forum/repo-discuss[
+  mailing list], the
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
+  and the link:https://www.gerritcodereview.com/[homepage]
+* gain permissions to do Gerrit releases and publish release artifacts
+* create new projects and groups on
+  link:https://gerrit-review.googlesource.com/[
+  gerrit-review.googlesource.com]
+* administrate the Gerrit projects on
+  link:https://gerrit-review.googlesource.com/[
+  gerrit-review.googlesource.com] (e.g. edit ACLs, update project
+  configuration)
+* create events in the
+  link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
+  community calendar]
+
+In addition, maintainers from Google can:
+
+* approve/reject changes that update project dependencies (vote `-1` to
+  `+1` on the `Library-Compliance` label), see
+  link:dev-processes.html#upgrading-libraries[Upgrading Libraries]
+* edit permissions on the Gerrit core projects
+
+Maintainers can nominate new maintainers by posting a nomination on the
+non-public maintainers mailing list. Nominations are approved by
+consensus among the maintainers. This means maintainers can veto a
+nomination.
+
+To become a maintainer, a link:#contributor[contributor] should have a
+history of deep technical contributions across different parts of the
+core Gerrit codebase. However, it is not required to be an expert on
+everything. Things that we want to see from potential maintainers
+include:
+
+* high quality code contributions
+* high quality code reviews
+* activity on the mailing list
+
+[[steering-committee-member]]
+== Steering Committee Member
+
+The Gerrit project has a steering committee that governs the project,
+see link:dev-processes.html#project-governance[Project Governance].
+
+Members of the steering committee are expected to act in the interest
+of the Gerrit project and the whole Gerrit community.
+
+For those that are familiar with scrum, the steering committee member
+role is similar to the role of an agile product owner.
+
+Steering committee members must be able to dedicate sufficient time to
+their role so that the steering committee can satisfy its
+responsibilities and live up to the promise of answering incoming
+requests in a timely manner.
+
+link:#maintainer[Maintainers] can become steering committee member by
+election, or by being appointed by Google (only for the seats that
+belong to Google).
+
+[[mentor]]
+== Mentor
+
+A mentor is a link:#maintainer[maintainer] or link:#contributor[
+contributor] who is assigned to support the development of a feature
+that was specified in a link:dev-design-docs.html[design doc] and was
+approved by the link:dev-processes.html#steering-committee[steering
+committee].
+
+The goal of the mentor is to make the feature successful by:
+
+* doing timely reviews
+* providing technical guidance during code reviews
+* discussing details of the design
+* ensuring that the quality standards are met (well documented,
+  sufficient test coverage, backwards compatible etc.)
+
+The implementation is fully done by the contributor, but optionally
+mentors can help out with contributing some changes.
+
+link:#maintainer[Maintainers] and link:#contributor[contributors] can
+volunteer to generally serve as mentors, or to mentor specific features
+(e.g. if they see an upcoming feature on the roadmap that they are
+interested in). To volunteer as mentor, contact the
+link:dev-processes.html#steering-committee[steering committee] or
+comment on a change that adds a link:dev-design-docs.html#propose[
+design doc].
+
+[[community-manager]]
+== Community Manager
+
+Community managers should act as stakeholders for the Gerrit community
+and focus on the health of the community.
+
+Tasks:
+
+* act as stakeholder for the Gerrit community towards the
+  link:dev-processes.html#steering-committee[steering committee]
+* ensure that the link:dev-contributing.html#mentorship[mentorship
+  process] works
+* deescalate conflicts in the Gerrit community
+* constantly improve community processes (e.g. contribution process)
+* watch out for community issues and address them proactively
+* serve as contact person for community issues
+
+The community managers should be a pair of two that share the work:
+
+* one Googler that is appointed by Google
+* one non-Googler, elected by the community if there are multiple
+  candidates (if there is no candidate, we only have the one community
+  manager from Google)
+
+Community managers must not be link:#steering-committee-member[
+steering committee members] at the same time so that they can represent
+the community without conflict of interest.
+
+Nomination process, election process and election period for the
+non-Google community manager are the same as for
+link:dev-processes.html#steering-committee-election[steering committee
+members].
+
+[[release-manager]]
+== Release Manager
+
+Each major Gerrit release is driven by a Gerrit link:#maintainer[
+maintainer], the so called release manager.
+
+The release manager is responsible for:
+
+* identifying release blockers and informing about them
+* creating stable branches and updating version numbers
+* creating release candidates, the final major release and minor
+  releases
+* announcing releases on the mailing list and collecting feedback
+* ensuring that releases meet minimal quality expectations (Gerrit
+  starts, upgrade from previous version works)
+* publishing release artifacts
+* ensuring quality and completeness of the release notes
+* cherry-picking bug fixes, see link:dev-processes.html#backporting[
+  Backporting to stable branches]
+* estimating the risk of new features that are added on stable
+  branches, see link:dev-processes.html#dev-in-stable-branches[
+  Development in stable branches]
+
+Before each release, the release manager is appointed by consensus among
+the maintainers. Volunteers are welcome, but it's also a goal to fairly
+share this work between maintainers and contributing companies.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-starter-projects.txt b/Documentation/dev-starter-projects.txt
new file mode 100644
index 0000000..ae40ea6
--- /dev/null
+++ b/Documentation/dev-starter-projects.txt
@@ -0,0 +1,14 @@
+= Gerrit Code Review - Starter Projects
+
+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
+link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/i18n-readme.txt b/Documentation/i18n-readme.txt
deleted file mode 100644
index 180fc53..0000000
--- a/Documentation/i18n-readme.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-= Gerrit Code Review - i18n
-
-Aside from actually writing translations, there are some issues with
-the way the code produces output.  Most of the UI should support
-right-to-left (RTL) languages.
-
-== Labels
-
-Labels and their values are defined in project.config by the Gerrit
-administrator or project owners.  Only a single translation of these
-strings is supported.
-
-== /Gerrit Gerrit.html
-
-* The title of the host page is not translated.
-
-* The <noscript> tag is not translated.
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 557cf90..a8b1a27 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -9,6 +9,7 @@
 . link:intro-quick.html[Product Overview]
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
+. link:dev-community.html[Gerrit Community]
 
 == Guides
 . link:intro-user.html[User Guide]
@@ -72,28 +73,6 @@
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
 
-== Developer
-. Getting Started
-.. link:dev-readme.html[Developer Setup]
-.. link:dev-bazel.html[Building with Bazel]
-.. link:dev-eclipse.html[Eclipse Setup]
-.. link:dev-intellij.html[IntelliJ Setup]
-.. link:dev-contributing.html[Contributing to Gerrit]
-. Plugin Development
-.. link:dev-plugins.html[Developing Plugins]
-.. link:dev-build-plugins.html[Building Gerrit plugins]
-.. link:js-api.html[JavaScript Plugin API]
-.. link:config-validation.html[Validation Interfaces]
-.. link:dev-stars.html[Starring Changes]
-.. link:quota.html[Quota Enforcement]
-. link:dev-design.html[System Design]
-. link:i18n-readme.html[i18n Support]
-
-== Maintainer
-. link:dev-release.html[Making a Gerrit Release]
-. link:dev-release-subproject.html[Making a Release of a Gerrit Subproject]
-. link:dev-release-jgit.html[Making a Release of JGit]
-
 == Concepts
 . link:config-labels.html[Review Labels]
 . link:access-control.html[Access Controls]
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index bfebc6a..c2dcedb 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -43,7 +43,8 @@
 From the command line, enter:
 
 ....
-java -jar gerrit*.war init --batch --dev -d ~/gerrit_testsite
+export GERRIT_SITE=~/gerrit_testsite
+java -jar gerrit*.war init --batch --dev -d $GERRIT_SITE
 ....
 
 This command takes two parameters:
@@ -78,7 +79,7 @@
 `localhost`. For example:
 
 ....
-git config --file ~/gerrit_testsite/etc/gerrit.config httpd.listenUrl 'http://localhost:8080'
+git config --file $GERRIT_SITE/etc/gerrit.config httpd.listenUrl 'http://localhost:8080'
 ....
 
 == Restart the Gerrit service
@@ -87,7 +88,7 @@
 changes to take effect:
 
 ....
-~/gerrit_testsite/bin/gerrit.sh restart
+$GERRIT_SITE/bin/gerrit.sh restart
 ....
 
 == Viewing Gerrit
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 064859d..49bed97 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -15,8 +15,8 @@
 
 === Actions
 
-* `action/retry_attempt_counts`: Distribution of number of attempts made
-by RetryHelper to execute an action (1 == single attempt, no retry)
+* `action/retry_attempt_count`: Number of retry attempts made
+by RetryHelper to execute an action (0 == single attempt, no retry)
 * `action/retry_timeout_count`: Number of action executions of RetryHelper
 that ultimately timed out
 
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 9a23a27..f291920 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -74,6 +74,9 @@
 link:pgm-prolog-shell.html[prolog-shell] program which opens an interactive
 Prolog interpreter shell.
 
+For batch or unit tests, see the examples in Gerrit source directory
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/prologtests/examples/[prologtests/examples].
+
 [NOTE]
 The interactive shell is just a prolog shell, it does not load
 a gerrit server environment and thus is not intended for
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
deleted file mode 100755
index 2e01131..0000000
--- a/contrib/abandon_stale.py
+++ /dev/null
@@ -1,225 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# The MIT License
-#
-# Copyright 2014 Sony Mobile Communications. 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, 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.
-
-""" Script to abandon stale changes from the review server.
-
-Fetches a list of open changes that have not been updated since a given age in
-days, months or years (default 6 months), and then abandons them.
-
-Requires the user's credentials for the Gerrit server to be declared in the
-.netrc file. Supports either basic or digest authentication.
-
-Example to abandon changes that have not been updated for 3 months:
-
-  ./abandon_stale --gerrit-url http://review.example.com/ --age 3months
-
-Supports dry-run mode to only list the stale changes, but not actually
-abandon them.
-
-See the --help output for more information about options.
-
-Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2) to be installed
-and available for import.
-
-"""
-
-import logging
-import optparse
-import re
-import sys
-
-from pygerrit2.rest import GerritRestAPI
-from pygerrit2.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
-
-
-def _main():
-    parser = optparse.OptionParser()
-    parser.add_option('-g', '--gerrit-url', dest='gerrit_url',
-                      metavar='URL',
-                      default=None,
-                      help='gerrit server URL')
-    parser.add_option('-b', '--basic-auth', dest='basic_auth',
-                      action='store_true',
-                      help='(deprecated) use HTTP basic authentication instead'
-                      ' of digest')
-    parser.add_option('-d', '--digest-auth', dest='digest_auth',
-                      action='store_true',
-                      help='use HTTP digest authentication instead of basic')
-    parser.add_option('-n', '--dry-run', dest='dry_run',
-                      action='store_true',
-                      help='enable dry-run mode: show stale changes but do '
-                           'not abandon them')
-    parser.add_option('-t', '--test', dest='testmode', action='store_true',
-                      help='test mode: query changes with the `test-abandon` '
-                           'topic and ignore age option')
-    parser.add_option('-a', '--age', dest='age',
-                      metavar='AGE',
-                      default="6months",
-                      help='age of change since last update in days, months'
-                           ' or years (default: %default)')
-    parser.add_option('-m', '--message', dest='message',
-                      metavar='STRING', default=None,
-                      help='custom message to append to abandon message')
-    parser.add_option('--branch', dest='branches', metavar='BRANCH_NAME',
-                      default=[], action='append',
-                      help='abandon changes only on the given branch')
-    parser.add_option('--exclude-branch', dest='exclude_branches',
-                      metavar='BRANCH_NAME',
-                      default=[],
-                      action='append',
-                      help='do not abandon changes on given branch')
-    parser.add_option('--project', dest='projects', metavar='PROJECT_NAME',
-                      default=[], action='append',
-                      help='abandon changes only on the given project')
-    parser.add_option('--exclude-project', dest='exclude_projects',
-                      metavar='PROJECT_NAME',
-                      default=[],
-                      action='append',
-                      help='do not abandon changes on given project')
-    parser.add_option('--owner', dest='owner',
-                      metavar='USERNAME',
-                      default=None,
-                      action='store',
-                      help='only abandon changes owned by the given user')
-    parser.add_option('--exclude-wip', dest='exclude_wip',
-                      action='store_true',
-                      help='Exclude changes that are Work-in-Progress')
-    parser.add_option('-v', '--verbose', dest='verbose',
-                      action='store_true',
-                      help='enable verbose (debug) logging')
-
-    (options, _args) = parser.parse_args()
-
-    level = logging.DEBUG if options.verbose else logging.INFO
-    logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
-                        level=level)
-
-    if not options.gerrit_url:
-        logging.error("Gerrit URL is required")
-        return 1
-
-    if options.testmode:
-        message = "Abandoning in test mode"
-    else:
-        pattern = re.compile(r"^([\d]+)(month[s]?|year[s]?|week[s]?)")
-        match = pattern.match(options.age)
-        if not match:
-            logging.error("Invalid age: %s", options.age)
-            return 1
-        message = "Abandoning after %s %s or more of inactivity." % \
-            (match.group(1), match.group(2))
-
-    if options.digest_auth:
-        auth_type = HTTPDigestAuthFromNetrc
-    else:
-        auth_type = HTTPBasicAuthFromNetrc
-
-    try:
-        auth = auth_type(url=options.gerrit_url)
-        gerrit = GerritRestAPI(url=options.gerrit_url, auth=auth)
-    except Exception as e:
-        logging.error(e)
-        return 1
-
-    logging.info(message)
-    try:
-        stale_changes = []
-        offset = 0
-        step = 500
-        if options.testmode:
-            query_terms = ["status:new", "owner:self", "topic:test-abandon"]
-        else:
-            query_terms = ["status:new", "age:%s" % options.age]
-        if options.exclude_wip:
-            query_terms += ["-is:wip"]
-        if options.branches:
-            query_terms += ["branch:%s" % b for b in options.branches]
-        elif options.exclude_branches:
-            query_terms += ["-branch:%s" % b for b in options.exclude_branches]
-        if options.projects:
-            query_terms += ["project:%s" % p for p in options.projects]
-        elif options.exclude_projects:
-            query_terms = ["-project:%s" % p for p in options.exclude_projects]
-        if options.owner and not options.testmode:
-            query_terms += ["owner:%s" % options.owner]
-        query = "%20".join(query_terms)
-        while True:
-            q = query + "&o=DETAILED_ACCOUNTS&n=%d&S=%d" % (step, offset)
-            logging.debug("Query: %s", q)
-            url = "/changes/?q=" + q
-            result = gerrit.get(url)
-            logging.debug("%d changes", len(result))
-            if not result:
-                break
-            stale_changes += result
-            last = result[-1]
-            if "_more_changes" in last:
-                logging.debug("More...")
-                offset += step
-            else:
-                break
-    except Exception as e:
-        logging.error(e)
-        return 1
-
-    abandoned = 0
-    errors = 0
-    abandon_message = message
-    if options.message:
-        abandon_message += "\n\n" + options.message
-    for change in stale_changes:
-        number = change["_number"]
-        project = ""
-        if len(options.projects) != 1:
-            project = "%s: " % change["project"]
-        owner = ""
-        if options.verbose:
-            try:
-                o = change["owner"]["name"]
-            except KeyError:
-                o = "Unknown"
-            owner = " (%s)" % o
-        subject = change["subject"]
-        if len(subject) > 70:
-            subject = subject[:65] + " [...]"
-        change_id = change["id"]
-        logging.info("%s%s: %s%s", number, owner, project, subject)
-        if options.dry_run:
-            continue
-
-        try:
-            gerrit.post("/changes/" + change_id + "/abandon",
-                        json={"message": "%s" % abandon_message})
-            abandoned += 1
-        except Exception as e:
-            errors += 1
-            logging.error(e)
-    logging.info("Total %d stale open changes", len(stale_changes))
-    if not options.dry_run:
-        logging.info("Abandoned %d changes. %d errors.", abandoned, errors)
-
-
-if __name__ == "__main__":
-    sys.exit(_main())
diff --git a/contrib/hooks/post-receive-move-tmp-refs b/contrib/hooks/post-receive-move-tmp-refs
new file mode 100755
index 0000000..c4a53db
--- /dev/null
+++ b/contrib/hooks/post-receive-move-tmp-refs
@@ -0,0 +1,79 @@
+#!/bin/sh
+#
+# 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.
+# --------------------------------------------------------
+# Install this hook script as post-receive hook in replicated repositories
+# hosted by a gerrit slave which are updated by push replication from the
+# corresponding gerrit master.
+#
+# In the gerrit master configure the replication plugin to push changes from
+# refs/changes/ to refs/tmp/changes/
+#   remote.NAME.push = +refs/changes/*:refs/tmp/changes/*
+#   remote.NAME.push = +refs/heads/*:refs/heads/*
+#   remote.NAME.push = +refs/tags/*:refs/tags/*
+#
+# In the replicated repository in the gerrit slave configure
+#    receive.hideRefs = refs/changes/
+# in order to not advertise the big number of refs in this namespace when
+# the gerrit master's replication plugin is pushing a change
+#
+# Whenever a ref under refs/tmp/changes/ is arriving this hook will move it
+# to refs/changes/. This helps to avoid the large overhead of advertising all
+# refs/changes/ refs to the gerrit master when it replicates changes to the
+# slave..
+#
+# Make this script executable then link to it in the repository you would like
+# to use it in.
+#   cd /path/to/your/repository.git
+#   ln -sf <shared hooks directory>/post-receive-move-tmp-refs hooks/post-receive
+#
+# If you want to use this by default for repositories on the Gerrit slave you
+# can set up a git template directory $TEMPLATE_DIR/hooks/post-receive and
+# configure init.templateDir in the ~/.gitconfig of the user that receives the
+# replication on the mirror host. That way when a new repository is created on
+# the master and hence on the mirror (if configured that way) it will
+# automatically have the "tmp-refs" commit hook installed.
+# See https://git-scm.com/docs/git-init#_template_directory for details.
+
+readonly NULL_SHA1=0000000000000000000000000000000000000000
+
+# move new changes arriving under refs/tmp/changes/ to refs/changes/
+mv_tmp_refs()
+{
+	oldrev=$1
+	newrev=$2
+	refname=$3
+	case "$refname","$oldrev" in
+		refs/tmp/changes/*,$NULL_SHA1)
+			short_refname=${refname##refs/tmp/changes/}
+			$(git update-ref refs/changes/$short_refname $newrev $NULL_SHA1 2>/dev/null)
+			$(git update-ref -d $refname $newrev 2>/dev/null)
+			echo "moved \"$refname\" to \"refs/changes/$short_refname\""
+			;;
+	esac
+	return 0
+}
+
+GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
+if [ -z "$GIT_DIR" ]; then
+	echo >&2 "fatal: post-receive: GIT_DIR not set"
+	exit 1
+fi
+
+# read ref updates passed to post-receive hook
+while read oldrev newrev refname
+do
+	mv_tmp_refs $oldrev $newrev $refname || continue
+done
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index bb6a4b4..043e142 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
@@ -36,11 +38,13 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
 import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
@@ -50,7 +54,6 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.Permission;
-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.extensions.api.GerritApi;
@@ -80,7 +83,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -159,7 +162,6 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -182,7 +184,6 @@
 import org.junit.Before;
 import org.junit.ClassRule;
 import org.junit.Rule;
-import org.junit.rules.ExpectedException;
 import org.junit.rules.TemporaryFolder;
 import org.junit.rules.TestRule;
 import org.junit.runner.Description;
@@ -199,8 +200,6 @@
   @ConfigSuite.Parameter public Config baseConfig;
   @ConfigSuite.Name private String configName;
 
-  @Rule public ExpectedException exception = ExpectedException.none();
-
   @Rule
   public TestRule testRunner =
       new TestRule() {
@@ -278,6 +277,9 @@
   protected boolean testRequiresSsh;
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
+  // TODO(dborowitz): Push down into callers that need it.
+  @Inject protected ProjectOperations projectOperations;
+
   @Inject private AbstractChangeNotes.Args changeNotesArgs;
   @Inject private AccountIndexCollection accountIndexes;
   @Inject private AccountIndexer accountIndexer;
@@ -287,7 +289,6 @@
   @Inject private PluginGuiceEnvironment pluginGuiceEnvironment;
   @Inject private PluginUser.Factory pluginUserFactory;
   @Inject private ProjectIndexCollection projectIndexes;
-  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private SitePaths sitePaths;
 
@@ -433,7 +434,7 @@
     atrScope.set(ctx);
     ProjectInput in = projectInput(description);
     gApi.projects().create(in);
-    project = new Project.NameKey(in.name);
+    project = Project.nameKey(in.name);
     if (!classDesc.skipProjectClone()) {
       testRepo = cloneProject(project, getCloneAsAccount(description));
     }
@@ -532,7 +533,7 @@
     in.submitType = submitType;
     in.createEmptyCommit = createEmptyCommit;
     gApi.projects().create(in);
-    return new Project.NameKey(in.name);
+    return Project.nameKey(in.name);
   }
 
   protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p) throws Exception {
@@ -690,18 +691,18 @@
     return push.to("refs/for/" + branch + "%topic=" + name(topic));
   }
 
-  protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
+  protected BranchApi createBranch(BranchNameKey branch) throws Exception {
     return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
+        .name(branch.project().get())
+        .branch(branch.branch())
         .create(new BranchInput());
   }
 
-  protected BranchApi createBranchWithRevision(Branch.NameKey branch, String revision)
+  protected BranchApi createBranchWithRevision(BranchNameKey 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.project().get()).branch(branch.branch()).create(in);
   }
 
   private static final List<Character> RANDOM =
@@ -776,7 +777,10 @@
 
   protected AccountState getAccountState(Account.Id accountId) {
     Optional<AccountState> accountState = accountCache.get(accountId);
-    assertThat(accountState).named("account %s", accountId.get()).isPresent();
+    assertWithMessage("account %s", accountId.get())
+        .about(optionals())
+        .that(accountState)
+        .isPresent();
     return accountState.get();
   }
 
@@ -882,23 +886,23 @@
     allow(project, ref, permission, id);
   }
 
-  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.allow(u.getConfig(), permission, id, ref);
-      u.save();
-    }
+  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id) {
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(TestProjectUpdate.allow(permission).ref(ref).group(id))
+        .update();
   }
 
   protected void allowGlobalCapabilities(
       AccountGroup.UUID id, int min, int max, String... capabilityNames) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.allow(
-            u.getConfig(), capabilityName, id, new PermissionRange(capabilityName, min, max));
-      }
-      u.save();
-    }
+    // TODO(dborowitz): When inlining:
+    // * add a variant that takes a single String
+    // * explicitly add multiple values in callers instead of looping
+    TestProjectUpdate.Builder b = projectOperations.project(allProjects).forUpdate();
+    Arrays.stream(capabilityNames)
+        .forEach(c -> b.add(TestProjectUpdate.allowCapability(c).group(id).range(min, max)));
+    b.update();
   }
 
   protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
@@ -908,12 +912,13 @@
 
   protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
       throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (String capabilityName : capabilityNames) {
-        Util.allow(u.getConfig(), capabilityName, id);
-      }
-      u.save();
-    }
+    // TODO(dborowitz): When inlining:
+    // * add a variant that takes a single String
+    // * explicitly add multiple values in callers instead of looping
+    TestProjectUpdate.Builder b = projectOperations.project(allProjects).forUpdate();
+    Streams.stream(capabilityNames)
+        .forEach(c -> b.add(TestProjectUpdate.allowCapability(c).group(id)));
+    b.update();
   }
 
   protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
@@ -955,44 +960,50 @@
 
   protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
-    try (ProjectConfigUpdate u = updateProject(p)) {
-      Util.deny(u.getConfig(), permission, id, ref);
-      u.save();
-    }
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(TestProjectUpdate.deny(permission).ref(ref).group(id))
+        .update();
   }
 
-  protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    return block(project, ref, permission, id);
+  protected void block(String ref, String permission, AccountGroup.UUID id) throws Exception {
+    block(project, ref, permission, id);
   }
 
-  protected PermissionRule block(
-      Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
+  protected void block(Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      PermissionRule rule = Util.block(u.getConfig(), permission, id, ref);
-      u.save();
-      return rule;
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(TestProjectUpdate.block(permission).ref(ref).group(id))
+        .update();
   }
 
   protected void blockLabel(
       String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
       throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(u.getConfig(), Permission.LABEL + label, min, max, id, ref);
-      u.save();
-    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(TestProjectUpdate.blockLabel(label).ref(ref).group(id).range(min, max))
+        .update();
   }
 
-  protected void grant(Project.NameKey project, String ref, String permission)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(project, ref, permission, false);
+  protected void grant(Project.NameKey project, String ref, String permission) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(TestProjectUpdate.allow(permission).ref(ref).group(adminGroupUuid()))
+        .update();
   }
 
-  protected void grant(Project.NameKey project, String ref, String permission, boolean force)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(project, ref, permission, force, adminGroupUuid());
+  protected void grant(Project.NameKey project, String ref, String permission, boolean force) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(TestProjectUpdate.allow(permission).ref(ref).group(adminGroupUuid()).force(force))
+        .update();
   }
 
   protected void grant(
@@ -1000,19 +1011,12 @@
       String ref,
       String permission,
       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 = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      PermissionRule rule = Util.newRule(config, groupUUID);
-      rule.setForce(force);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
+      AccountGroup.UUID groupUUID) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(TestProjectUpdate.allow(permission).ref(ref).group(groupUUID).force(force))
+        .update();
   }
 
   protected void grantLabel(
@@ -1021,25 +1025,18 @@
       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 = projectConfigFactory.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());
-    }
+      boolean exclusive) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel(label)
+                .ref(ref)
+                .group(groupUUID)
+                .range(min, max)
+                .exclusive(exclusive))
+        .update();
   }
 
   protected void removePermission(Project.NameKey project, String ref, String permission)
@@ -1087,7 +1084,7 @@
   }
 
   protected PatchSet getPatchSet(PatchSet.Id psId) {
-    return changeDataFactory.create(project, psId.getParentKey()).patchSet(psId);
+    return changeDataFactory.create(project, psId.changeId()).patchSet(psId);
   }
 
   protected IdentifiedUser user(TestAccount testAccount) {
@@ -1107,7 +1104,7 @@
 
   protected RevisionResource parseRevisionResource(PushOneCommit.Result r) throws Exception {
     PatchSet.Id psId = r.getPatchSetId();
-    return parseRevisionResource(psId.getParentKey().toString(), psId.get());
+    return parseRevisionResource(psId.changeId().toString(), psId.get());
   }
 
   protected ChangeResource parseChangeResource(String changeId) throws Exception {
@@ -1138,7 +1135,7 @@
     assertThat(replyTo.getString()).contains(email);
   }
 
-  protected Map<Branch.NameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
+  protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
     try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
       return fetchFromBundles(result);
     }
@@ -1150,7 +1147,7 @@
    *
    * <p>Omits NoteDb meta refs.
    */
-  protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
+  protected Map<BranchNameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
     assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
 
     FileSystem fs = Jimfs.newFileSystem();
@@ -1158,7 +1155,7 @@
     try (OutputStream out = Files.newOutputStream(previewPath)) {
       bundles.writeTo(out);
     }
-    Map<Branch.NameKey, ObjectId> ret = new HashMap<>();
+    Map<BranchNameKey, ObjectId> ret = new HashMap<>();
     try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, null);
         DirectoryStream<Path> dirStream =
             Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
@@ -1170,7 +1167,7 @@
         int len = bundleName.length();
         assertThat(bundleName).endsWith(".git");
         String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = new Project.NameKey(repoName);
+        Project.NameKey proj = Project.nameKey(repoName);
         TestRepository<?> localRepo = cloneProject(proj);
 
         try (InputStream bundleStream = Files.newInputStream(p);
@@ -1187,7 +1184,7 @@
               continue;
             }
             RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
+            ret.put(BranchNameKey.create(proj, refName), c.getTree().copy());
           }
         }
       }
@@ -1197,18 +1194,18 @@
   }
 
   /** Assert that the given branches have the given tree ids. */
-  protected void assertTrees(Project.NameKey proj, Map<Branch.NameKey, ObjectId> trees)
+  protected void assertTrees(Project.NameKey proj, Map<BranchNameKey, ObjectId> trees)
       throws Exception {
     TestRepository<?> localRepo = cloneProject(proj);
     GitUtil.fetch(localRepo, "refs/*:refs/*");
-    Map<Branch.NameKey, RevTree> refValues = new HashMap<>();
+    Map<BranchNameKey, RevTree> refValues = new HashMap<>();
 
-    for (Branch.NameKey b : trees.keySet()) {
-      if (!b.getParentKey().equals(proj)) {
+    for (BranchNameKey b : trees.keySet()) {
+      if (!b.project().equals(proj)) {
         continue;
       }
 
-      Ref r = localRepo.getRepository().exactRef(b.get());
+      Ref r = localRepo.getRepository().exactRef(b.branch());
       assertThat(r).isNotNull();
       RevWalk rw = localRepo.getRevWalk();
       RevCommit c = rw.parseCommit(r.getObjectId());
@@ -1312,7 +1309,7 @@
 
   protected InternalGroup group(AccountGroup.UUID groupUuid) {
     InternalGroup group = groupCache.get(groupUuid).orElse(null);
-    assertThat(group).named(groupUuid.get()).isNotNull();
+    assertWithMessage(groupUuid.get()).that(group).isNotNull();
     return group;
   }
 
@@ -1322,13 +1319,13 @@
   }
 
   protected InternalGroup group(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
-    assertThat(group).named(groupName).isNotNull();
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
+    assertWithMessage(groupName).that(group).isNotNull();
     return group;
   }
 
   protected GroupReference groupRef(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
     assertThat(group).isNotNull();
     return new GroupReference(group.getGroupUUID(), group.getName());
   }
@@ -1350,8 +1347,8 @@
   }
 
   protected void assertGroupDoesNotExist(String groupName) {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
-    assertThat(group).named(groupName).isNull();
+    InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
+    assertWithMessage(groupName).that(group).isNull();
   }
 
   protected void assertNotifyTo(TestAccount expected) {
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index af2f17e..d7bcce2 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -93,6 +93,7 @@
 
   protected static class FakeEmailSenderSubject
       extends Subject<FakeEmailSenderSubject, FakeEmailSender> {
+    private final FakeEmailSender fakeEmailSender;
     private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
@@ -100,10 +101,11 @@
 
     FakeEmailSenderSubject(FailureMetadata failureMetadata, FakeEmailSender target) {
       super(failureMetadata, target);
+      fakeEmailSender = target;
     }
 
     public FakeEmailSenderSubject didNotSend() {
-      Message message = actual().peekMessage();
+      Message message = fakeEmailSender.peekMessage();
       if (message != null) {
         failWithoutActual(fact("expected no message", message));
       }
@@ -111,7 +113,7 @@
     }
 
     public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
-      message = actual().nextMessage();
+      message = fakeEmailSender.nextMessage();
       if (message == null) {
         failWithoutActual(fact("expected message", "not sent"));
       }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index aeae2c2..898c5c7 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -76,7 +76,7 @@
     if (account != null) {
       return account;
     }
-    Account.Id id = new Account.Id(sequences.nextAccountId());
+    Account.Id id = Account.id(sequences.nextAccountId());
 
     List<ExternalId> extIds = new ArrayList<>(2);
     String httpPass = null;
@@ -98,7 +98,7 @@
 
     if (groupNames != null) {
       for (String n : groupNames) {
-        AccountGroup.NameKey k = new AccountGroup.NameKey(n);
+        AccountGroup.NameKey k = AccountGroup.nameKey(n);
         Optional<InternalGroup> group = groupCache.get(k);
         if (!group.isPresent()) {
           throw new NoSuchGroupException(n);
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index ed0de9c..ada2fb6 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -14,6 +14,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
         "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/git/testing",
         "//java/com/google/gerrit/gpg/testing:gpg-test-util",
         "//java/com/google/gerrit/httpd",
@@ -34,6 +35,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
         "//lib:args4j",
         "//lib:gson",
         "//lib:guava-retrying",
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
index cdfdae7..7d5bcab 100644
--- a/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -114,14 +115,13 @@
     // 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.Builder b = new InMemoryRepository.Builder().setRepositoryDescription(desc);
+    if (uri.startsWith("ssh://")) {
+      // SshTransport depends on a real FS to read ~/.ssh/config, but InMemoryRepository by default
+      // uses a null FS.
+      b.setFS(fs);
+    }
+    InMemoryRepository dest = b.build();
     Config cfg = dest.getConfig();
     cfg.setString("remote", "origin", "url", uri);
     cfg.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
@@ -134,11 +134,6 @@
     return testRepo;
   }
 
-  public static TestRepository<InMemoryRepository> cloneProject(
-      Project.NameKey project, SshSession sshSession) throws Exception {
-    return cloneProject(project, sshSession.getUrl() + "/" + project.get());
-  }
-
   public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
       throws GitAPIException {
     TagCommand cmd =
@@ -209,13 +204,13 @@
 
   public static void assertPushOk(PushResult result, String ref) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).named(rru.toString()).isEqualTo(RemoteRefUpdate.Status.OK);
+    assertWithMessage(rru.toString()).that(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
   }
 
   public static void assertPushRejected(PushResult result, String ref, String expectedMessage) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus())
-        .named(rru.toString())
+    assertWithMessage(rru.toString())
+        .that(rru.getStatus())
         .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
     assertThat(rru.getMessage()).isEqualTo(expectedMessage);
   }
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index a704d2f..a3207e2 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -55,8 +55,6 @@
   @Override
   protected void configure() {
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-
-    // TODO(dborowitz): Use jimfs.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
     if (repoManager != null) {
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 7a79ce4..4a203be 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -83,8 +83,6 @@
       @Provides
       @RemotePeer
       SocketAddress getSocketAddress() {
-        // TODO(dborowitz): Could potentially fake this with thread ID or
-        // something.
         throw new OutOfScopeException("No remote peer in acceptance tests");
       }
     };
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index e15dd40..3fcf895 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static org.junit.Assert.assertEquals;
 
@@ -395,15 +396,15 @@
     public void assertErrorStatus() {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus())
-          .named(message(refUpdate))
+      assertWithMessage(message(refUpdate))
+          .that(refUpdate.getStatus())
           .isEqualTo(Status.REJECTED_OTHER_REASON);
     }
 
     private void assertStatus(Status expectedStatus, String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus()).named(message(refUpdate)).isEqualTo(expectedStatus);
+      assertWithMessage(message(refUpdate)).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
       if (expectedMessage == null) {
         assertThat(refUpdate.getMessage()).isNull();
       } else {
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index 29d0b35..eac3b0a 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.util.stream.Collectors.joining;
 import static org.junit.Assert.fail;
 
@@ -205,8 +205,8 @@
     // Use invokeProgram with the current classloader, rather than mainImpl, which would create a
     // new classloader. This is necessary so that static state, particularly the SystemReader, is
     // shared with the test method.
-    assertThat(GerritLauncher.invokeProgram(StandaloneSiteTest.class.getClassLoader(), args))
-        .named("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+    assertWithMessage("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+        .that(GerritLauncher.invokeProgram(StandaloneSiteTest.class.getClassLoader(), args))
         .isEqualTo(0);
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 7641e47..0228bac 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -68,7 +68,7 @@
 
   private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
       throws IOException, ConfigInvalidException {
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     return accountsUpdate.insert("Create Test Account", accountId, accountUpdater);
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index e0ddee5..808f858 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -78,10 +78,10 @@
   }
 
   private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation) {
-    AccountGroup.Id groupId = new AccountGroup.Id(seq.nextGroupId());
+    AccountGroup.Id groupId = AccountGroup.id(seq.nextGroupId());
     String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
     AccountGroup.UUID groupUuid = GroupUUID.make(groupName, serverIdent);
-    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(groupName);
+    AccountGroup.NameKey nameKey = AccountGroup.nameKey(groupName);
     return InternalGroupCreation.builder()
         .setId(groupId)
         .setGroupUUID(groupUuid)
@@ -153,7 +153,7 @@
 
     private InternalGroupUpdate toInternalGroupUpdate(TestGroupUpdate groupUpdate) {
       InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
-      groupUpdate.name().map(AccountGroup.NameKey::new).ifPresent(builder::setName);
+      groupUpdate.name().map(AccountGroup::nameKey).ifPresent(builder::setName);
       groupUpdate.description().ifPresent(builder::setDescription);
       groupUpdate.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
       groupUpdate.visibleToAll().ifPresent(builder::setVisibleToAll);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
index 029d161..fc4caf8 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectConfig;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -40,5 +42,33 @@
      * fully qualified refname ("refs/heads/master").
      */
     boolean hasHead(String branchName);
+
+    /** Returns a fresh {@link ProjectConfig} read from the tip of {@code refs/meta/config}. */
+    ProjectConfig getProjectConfig();
+
+    /**
+     * Returns a fresh JGit {@link Config} instance read from {@code project.config} at the tip of
+     * {@code refs/meta/config}. Does not have a base config, i.e. does not respect {@code
+     * $site_path/etc/project.config}.
+     */
+    Config getConfig();
+
+    /**
+     * Starts the fluent chain to update a project. The returned builder can be used to specify how
+     * the attributes of the project should be modified. To update the project for real, the {@link
+     * TestProjectUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * projectOperations
+     *     .forUpdate()
+     *     .add(allow(ABANDON).ref("refs/*").group(REGISTERED_USERS))
+     *     .update();
+     * </pre>
+     *
+     * @return a builder to update the check.
+     */
+    TestProjectUpdate.Builder forUpdate();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 28be3f3..6835ae4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -14,30 +14,62 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
-import com.google.common.base.Preconditions;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectCreation.Builder;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+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.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectCreator;
+import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import org.apache.commons.lang.RandomStringUtils;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectLoader;
 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.treewalk.TreeWalk;
 
 public class ProjectOperationsImpl implements ProjectOperations {
-  private final ProjectCreator projectCreator;
   private final GitRepositoryManager repoManager;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectCache projectCache;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCreator projectCreator;
 
   @Inject
-  ProjectOperationsImpl(GitRepositoryManager repoManager, ProjectCreator projectCreator) {
+  ProjectOperationsImpl(
+      GitRepositoryManager repoManager,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectCache projectCache,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCreator projectCreator) {
     this.repoManager = repoManager;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectCache = projectCache;
+    this.projectConfigFactory = projectConfigFactory;
     this.projectCreator = projectCreator;
   }
 
@@ -58,7 +90,7 @@
     args.ownerIds = new ArrayList<>();
     projectCreation.submitType().ifPresent(st -> args.submitType = st);
     projectCreator.createProject(args);
-    return new Project.NameKey(name);
+    return Project.nameKey(name);
   }
 
   @Override
@@ -67,7 +99,6 @@
   }
 
   private class PerProjectOperations implements ProjectOperations.PerProjectOperations {
-
     Project.NameKey nameKey;
 
     PerProjectOperations(Project.NameKey nameKey) {
@@ -76,7 +107,7 @@
 
     @Override
     public RevCommit getHead(String branch) {
-      return Preconditions.checkNotNull(headOrNull(branch));
+      return requireNonNull(headOrNull(branch));
     }
 
     @Override
@@ -84,6 +115,60 @@
       return headOrNull(branch) != null;
     }
 
+    @Override
+    public TestProjectUpdate.Builder forUpdate() {
+      return TestProjectUpdate.builder(this::updateProject);
+    }
+
+    private void updateProject(TestProjectUpdate projectUpdate)
+        throws IOException, ConfigInvalidException {
+      try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
+        ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+        addCapabilities(projectConfig, projectUpdate.addedCapabilities());
+        addPermissions(projectConfig, projectUpdate.addedPermissions());
+        addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
+        projectConfig.commit(metaDataUpdate);
+      }
+      projectCache.evict(nameKey);
+    }
+
+    private void addCapabilities(
+        ProjectConfig projectConfig, ImmutableList<TestCapability> addedCapabilities) {
+      for (TestCapability c : addedCapabilities) {
+        PermissionRule rule = Util.newRule(projectConfig, c.group());
+        rule.setRange(c.min(), c.max());
+        projectConfig
+            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+            .getPermission(c.name(), true)
+            .add(rule);
+      }
+    }
+
+    private void addPermissions(
+        ProjectConfig projectConfig, ImmutableList<TestPermission> addedPermissions) {
+      for (TestPermission p : addedPermissions) {
+        PermissionRule rule = Util.newRule(projectConfig, p.group());
+        rule.setAction(p.action());
+        rule.setForce(p.force());
+        projectConfig.getAccessSection(p.ref(), true).getPermission(p.name(), true).add(rule);
+      }
+    }
+
+    private void addLabelPermissions(
+        ProjectConfig projectConfig, ImmutableList<TestLabelPermission> addedLabelPermissions) {
+      for (TestLabelPermission p : addedLabelPermissions) {
+        PermissionRule rule = Util.newRule(projectConfig, p.group());
+        rule.setAction(p.action());
+        rule.setRange(p.min(), p.max());
+        Permission permission =
+            projectConfig
+                .getAccessSection(p.ref(), true)
+                .getPermission(Permission.forLabel(p.name()), true);
+        permission.setExclusiveGroup(p.exclusive());
+        permission.add(rule);
+      }
+    }
+
     private RevCommit headOrNull(String branch) {
       if (!branch.startsWith(Constants.R_REFS)) {
         branch = RefNames.REFS_HEADS + branch;
@@ -97,5 +182,39 @@
         throw new IllegalStateException(e);
       }
     }
+
+    @Override
+    public ProjectConfig getProjectConfig() {
+      try (Repository repo = repoManager.openRepository(nameKey)) {
+        ProjectConfig projectConfig = projectConfigFactory.create(nameKey);
+        projectConfig.load(nameKey, repo);
+        return projectConfig;
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    @Override
+    public Config getConfig() {
+      try (Repository repo = repoManager.openRepository(nameKey);
+          RevWalk rw = new RevWalk(repo)) {
+        Ref ref = repo.exactRef(REFS_CONFIG);
+        if (ref == null) {
+          return new Config();
+        }
+        RevTree tree = rw.parseTree(ref.getObjectId());
+        TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), PROJECT_CONFIG, tree);
+        if (tw == null) {
+          return new Config();
+        }
+        ObjectLoader loader = rw.getObjectReader().open(tw.getObjectId(0));
+        String text = new String(loader.getCachedBytes(), UTF_8);
+        Config config = new Config();
+        config.fromText(text);
+        return config;
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+    }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
new file mode 100644
index 0000000..b58eae6
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -0,0 +1,288 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.testsuite.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+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.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestProjectUpdate {
+  /** Starts a builder for allowing a capability. */
+  public static TestCapability.Builder allowCapability(String name) {
+    return TestCapability.builder().name(name);
+  }
+
+  /** Records a global capability to be updated. */
+  @AutoValue
+  public abstract static class TestCapability {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestCapability.Builder();
+    }
+
+    abstract String name();
+
+    abstract AccountGroup.UUID group();
+
+    abstract int min();
+
+    abstract int max();
+
+    /** Builder for {@link TestCapability}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      /** Sets the name of the capability. */
+      public abstract Builder name(String name);
+
+      abstract String name();
+
+      /** Sets the group to which the capability applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      abstract Builder min(int min);
+
+      abstract Optional<Integer> min();
+
+      abstract Builder max(int max);
+
+      abstract Optional<Integer> max();
+
+      /** Sets the minimum and maximum values for the capability. */
+      public Builder range(int min, int max) {
+        return min(min).max(max);
+      }
+
+      /** Builds the {@link TestCapability}. */
+      abstract TestCapability autoBuild();
+
+      public TestCapability build() {
+        if (min().isPresent() || max().isPresent()) {
+          checkArgument(
+              GlobalCapability.hasRange(name()), "capability %s does not support ranges", name());
+        }
+        PermissionRange.WithDefaults withDefaults = GlobalCapability.getRange(name());
+        if (!min().isPresent()) {
+          min(withDefaults != null ? withDefaults.getDefaultMin() : 0);
+        }
+        if (!max().isPresent()) {
+          max(withDefaults != null ? withDefaults.getDefaultMax() : 0);
+        }
+        return autoBuild();
+      }
+    }
+  }
+
+  /** Starts a builder for allowing a permission. */
+  public static TestPermission.Builder allow(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a permission. */
+  public static TestPermission.Builder deny(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.DENY);
+  }
+
+  /** Starts a builder for blocking a permission. */
+  public static TestPermission.Builder block(String name) {
+    return TestPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+  }
+
+  /**
+   * Records a permission to be updated.
+   *
+   * <p>Not used for permissions that have ranges (label permissions) or global capabilities.
+   */
+  @AutoValue
+  public abstract static class TestPermission {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestPermission.Builder().force(false);
+    }
+
+    abstract String name();
+
+    abstract String ref();
+
+    abstract AccountGroup.UUID group();
+
+    abstract PermissionRule.Action action();
+
+    abstract boolean force();
+
+    /** Builder for {@link TestPermission}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder name(String name);
+
+      /** Sets the ref pattern used on the permission. */
+      public abstract Builder ref(String ref);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID groupUuid);
+
+      abstract Builder action(PermissionRule.Action action);
+
+      /** Sets whether the permission is a force permission. */
+      public abstract Builder force(boolean force);
+
+      /** Builds the {@link TestPermission}. */
+      public abstract TestPermission build();
+    }
+  }
+
+  /** Starts a builder for allowing a label permission. */
+  public static TestLabelPermission.Builder allowLabel(String name) {
+    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a label permission. */
+  public static TestLabelPermission.Builder blockLabel(String name) {
+    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+  }
+
+  /** Records a label permission to be updated. */
+  @AutoValue
+  public abstract static class TestLabelPermission {
+    private static Builder builder() {
+      return new AutoValue_TestProjectUpdate_TestLabelPermission.Builder().exclusive(false);
+    }
+
+    abstract String name();
+
+    abstract String ref();
+
+    abstract AccountGroup.UUID group();
+
+    abstract PermissionRule.Action action();
+
+    abstract int min();
+
+    abstract int max();
+
+    abstract boolean exclusive();
+
+    /** Builder for {@link TestLabelPermission}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      abstract Builder name(String name);
+
+      /** Sets the ref pattern used on the permission. */
+      public abstract Builder ref(String ref);
+
+      /** Sets the group to which the permission applies. */
+      public abstract Builder group(AccountGroup.UUID group);
+
+      abstract Builder action(PermissionRule.Action action);
+
+      abstract Builder min(int min);
+
+      abstract Builder max(int max);
+
+      /** Sets the minimum and maximum values for the permission. */
+      public Builder range(int min, int max) {
+        return min(min).max(max);
+      }
+
+      /** Adds the permission to the exclusive group permission set on the access section. */
+      public abstract Builder exclusive(boolean exclusive);
+
+      abstract TestLabelPermission autoBuild();
+
+      /** Builds the {@link TestPermission}. */
+      public TestLabelPermission build() {
+        TestLabelPermission result = autoBuild();
+        checkArgument(
+            !Permission.isLabel(result.name()),
+            "expected label name, got permission name: %s",
+            result.name());
+        LabelType.checkName(result.name());
+        return result;
+      }
+    }
+  }
+
+  static Builder builder(ThrowingConsumer<TestProjectUpdate> projectUpdater) {
+    return new AutoValue_TestProjectUpdate.Builder().projectUpdater(projectUpdater);
+  }
+
+  /** Builder for {@link TestProjectUpdate}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    abstract ImmutableList.Builder<TestPermission> addedPermissionsBuilder();
+
+    abstract ImmutableList.Builder<TestLabelPermission> addedLabelPermissionsBuilder();
+
+    abstract ImmutableList.Builder<TestCapability> addedCapabilitiesBuilder();
+
+    /** Adds a permission to be included in this update. */
+    public Builder add(TestPermission testPermission) {
+      addedPermissionsBuilder().add(testPermission);
+      return this;
+    }
+
+    /** Adds a permission to be included in this update. */
+    public Builder add(TestPermission.Builder testPermissionBuilder) {
+      return add(testPermissionBuilder.build());
+    }
+
+    /** Adds a label permission to be included in this update. */
+    public Builder add(TestLabelPermission testLabelPermission) {
+      addedLabelPermissionsBuilder().add(testLabelPermission);
+      return this;
+    }
+
+    /** Adds a label permission to be included in this update. */
+    public Builder add(TestLabelPermission.Builder testLabelPermissionBuilder) {
+      return add(testLabelPermissionBuilder.build());
+    }
+
+    /** Adds a capability to be included in this update. */
+    public Builder add(TestCapability testCapability) {
+      addedCapabilitiesBuilder().add(testCapability);
+      return this;
+    }
+
+    /** Adds a capability to be included in this update. */
+    public Builder add(TestCapability.Builder testCapabilityBuilder) {
+      return add(testCapabilityBuilder.build());
+    }
+
+    abstract Builder projectUpdater(ThrowingConsumer<TestProjectUpdate> projectUpdater);
+
+    abstract TestProjectUpdate autoBuild();
+
+    /** Executes the update, updating the underlying project. */
+    public void update() {
+      TestProjectUpdate projectUpdate = autoBuild();
+      projectUpdate.projectUpdater().acceptAndThrowSilently(projectUpdate);
+    }
+  }
+
+  abstract ImmutableList<TestPermission> addedPermissions();
+
+  abstract ImmutableList<TestLabelPermission> addedLabelPermissions();
+
+  abstract ImmutableList<TestCapability> addedCapabilities();
+
+  abstract ThrowingConsumer<TestProjectUpdate> projectUpdater();
+}
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index a434c34..c597e98 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -22,7 +22,6 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gwtorm",
         "//java/org/eclipse/jgit:server",
         "//lib:guava",
         "//lib:servlet-api-3_1",
diff --git a/java/com/google/gerrit/common/FileUtil.java b/java/com/google/gerrit/common/FileUtil.java
index 04288bc..5b0925e 100644
--- a/java/com/google/gerrit/common/FileUtil.java
+++ b/java/com/google/gerrit/common/FileUtil.java
@@ -44,7 +44,6 @@
   }
 
   public static void chmod(int mode, Path path) {
-    // TODO(dborowitz): Is there a portable way to do this with NIO?
     chmod(mode, path.toFile());
   }
 
diff --git a/java/com/google/gerrit/common/PageLinks.java b/java/com/google/gerrit/common/PageLinks.java
index 97e7ff3..701c171 100644
--- a/java/com/google/gerrit/common/PageLinks.java
+++ b/java/com/google/gerrit/common/PageLinks.java
@@ -17,9 +17,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.KeyUtil;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.client.KeyUtil;
 
 public class PageLinks {
   public static final String PROJECT_CHANGE_DELIMITER = "/+/";
@@ -83,7 +83,7 @@
   }
 
   public static String toChange(@Nullable Project.NameKey project, PatchSet.Id ps) {
-    return toChange(project, ps.getParentKey()) + ps.getId();
+    return toChange(project, ps.changeId()) + ps.getId();
   }
 
   public static String toProject(Project.NameKey p) {
diff --git a/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
index 1ae246f..2eb97cf 100644
--- a/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/java/com/google/gerrit/common/data/CommentDetail.java
@@ -42,7 +42,7 @@
   protected CommentDetail() {}
 
   public void include(Change.Id changeId, Comment p) {
-    PatchSet.Id psId = new PatchSet.Id(changeId, p.key.patchSetId);
+    PatchSet.Id psId = PatchSet.id(changeId, p.key.patchSetId);
     if (p.side == 0) {
       if (idA == null && idB.equals(psId)) {
         a.add(p);
diff --git a/java/com/google/gerrit/common/data/GroupReference.java b/java/com/google/gerrit/common/data/GroupReference.java
index e5b0965..f0ca018 100644
--- a/java/com/google/gerrit/common/data/GroupReference.java
+++ b/java/com/google/gerrit/common/data/GroupReference.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
@@ -46,17 +48,27 @@
   /**
    * Create a group reference.
    *
-   * @param uuid UUID of the group, may be {@code null} if the group name couldn't be resolved
+   * @param uuid UUID of the group, must not be {@code null}
    * @param name the group name, must not be {@code null}
    */
-  public GroupReference(@Nullable AccountGroup.UUID uuid, String name) {
-    setUUID(uuid);
+  public GroupReference(AccountGroup.UUID uuid, String name) {
+    setUUID(requireNonNull(uuid));
+    setName(name);
+  }
+
+  /**
+   * Create a group reference where the group's name couldn't be resolved.
+   *
+   * @param name the group name, must not be {@code null}
+   */
+  public GroupReference(String name) {
+    setUUID(null);
     setName(name);
   }
 
   @Nullable
   public AccountGroup.UUID getUUID() {
-    return uuid != null ? new AccountGroup.UUID(uuid) : null;
+    return uuid != null ? AccountGroup.uuid(uuid) : null;
   }
 
   public void setUUID(@Nullable AccountGroup.UUID newUUID) {
diff --git a/java/com/google/gerrit/common/data/LabelFunction.java b/java/com/google/gerrit/common/data/LabelFunction.java
index 7d13c70..3c00cf5 100644
--- a/java/com/google/gerrit/common/data/LabelFunction.java
+++ b/java/com/google/gerrit/common/data/LabelFunction.java
@@ -98,18 +98,18 @@
     }
 
     for (PatchSetApproval a : approvals) {
-      if (a.getValue() == 0) {
+      if (a.value() == 0) {
         continue;
       }
 
       if (isBlock && labelType.isMaxNegative(a)) {
-        submitRecordLabel.appliedBy = a.getAccountId();
+        submitRecordLabel.appliedBy = a.accountId();
         submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
         return submitRecordLabel;
       }
 
       if (labelType.isMaxPositive(a) || !requiresMaxValue) {
-        submitRecordLabel.appliedBy = a.getAccountId();
+        submitRecordLabel.appliedBy = a.accountId();
 
         submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
         if (isRequired) {
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index be4c33c..25b8d19 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -155,7 +155,7 @@
   }
 
   public boolean matches(PatchSetApproval psa) {
-    return psa.getLabelId().get().equalsIgnoreCase(name);
+    return psa.labelId().get().equalsIgnoreCase(name);
   }
 
   public LabelFunction getFunction() {
@@ -279,11 +279,11 @@
   }
 
   public boolean isMaxNegative(PatchSetApproval ca) {
-    return maxNegative == ca.getValue();
+    return maxNegative == ca.value();
   }
 
   public boolean isMaxPositive(PatchSetApproval ca) {
-    return maxPositive == ca.getValue();
+    return maxPositive == ca.value();
   }
 
   public LabelValue getValue(short value) {
@@ -291,11 +291,11 @@
   }
 
   public LabelValue getValue(PatchSetApproval ca) {
-    return byValue.get(ca.getValue());
+    return byValue.get(ca.value());
   }
 
   public LabelId getLabelId() {
-    return new LabelId(name);
+    return LabelId.create(name);
   }
 
   @Override
diff --git a/java/com/google/gerrit/common/data/SubscribeSection.java b/java/com/google/gerrit/common/data/SubscribeSection.java
index aaf0798..60ac12a 100644
--- a/java/com/google/gerrit/common/data/SubscribeSection.java
+++ b/java/com/google/gerrit/common/data/SubscribeSection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -60,14 +60,14 @@
    * @param branch the branch to check
    * @return if the branch could trigger a superproject update
    */
-  public boolean appliesTo(Branch.NameKey branch) {
+  public boolean appliesTo(BranchNameKey branch) {
     for (RefSpec r : matchingRefSpecs) {
-      if (r.matchSource(branch.get())) {
+      if (r.matchSource(branch.branch())) {
         return true;
       }
     }
     for (RefSpec r : multiMatchRefSpecs) {
-      if (r.matchSource(branch.get())) {
+      if (r.matchSource(branch.branch())) {
         return true;
       }
     }
diff --git a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
index 265d590..61263fc 100644
--- a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
+++ b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
@@ -33,19 +33,20 @@
     return GroupReferenceSubject::new;
   }
 
+  private final GroupReference group;
+
   private GroupReferenceSubject(FailureMetadata metadata, GroupReference group) {
     super(metadata, group);
+    this.group = group;
   }
 
   public ComparableSubject<?, AccountGroup.UUID> groupUuid() {
     isNotNull();
-    GroupReference group = actual();
-    return check("groupUuid()").that(group.getUUID());
+    return check("getUUID()").that(group.getUUID());
   }
 
   public StringSubject name() {
     isNotNull();
-    GroupReference group = actual();
-    return check("name()").that(group.getName());
+    return check("getName()").that(group.getName());
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index c25aa90..60cdb64 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -118,7 +118,7 @@
       source = json.getAsJsonObject().get("fields");
     }
 
-    Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
+    Account.Id id = 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.
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 5782399..38f8578 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -208,7 +208,7 @@
       int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       String projectName = requireNonNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-      return changeDataFactory.create(new Project.NameKey(projectName), new Change.Id(id));
+      return changeDataFactory.create(Project.nameKey(projectName), Change.id(id));
     }
 
     ChangeData cd =
@@ -278,7 +278,7 @@
           if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
             break;
           }
-          accounts.add(new Account.Id(aId));
+          accounts.add(Account.id(aId));
         }
         cd.setReviewedBy(accounts);
       }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index ecda1ee..471bc4e 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -117,8 +117,7 @@
     }
 
     AccountGroup.UUID uuid =
-        new AccountGroup.UUID(
-            source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
+        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).orElse(null);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
index daf3702..cb97032 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -117,8 +117,7 @@
     }
 
     Project.NameKey nameKey =
-        new Project.NameKey(
-            source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
+        Project.nameKey(source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
     return projectCache.get().get(nameKey).toProjectData();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
index 3e6f762..711337a 100644
--- a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Optional;
 
 public abstract class GroupAuditEventInfo {
   public enum Type {
@@ -30,35 +31,35 @@
 
   public static UserMemberAuditEventInfo createAddUserEvent(
       AccountInfo user, Timestamp date, AccountInfo member) {
-    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date, member);
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, Optional.of(date), member);
   }
 
   public static UserMemberAuditEventInfo createRemoveUserEvent(
-      AccountInfo user, Timestamp date, AccountInfo member) {
+      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
     return new UserMemberAuditEventInfo(Type.REMOVE_USER, user, date, member);
   }
 
   public static GroupMemberAuditEventInfo createAddGroupEvent(
       AccountInfo user, Timestamp date, GroupInfo member) {
-    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date, member);
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, Optional.of(date), member);
   }
 
   public static GroupMemberAuditEventInfo createRemoveGroupEvent(
-      AccountInfo user, Timestamp date, GroupInfo member) {
+      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
     return new GroupMemberAuditEventInfo(Type.REMOVE_GROUP, user, date, member);
   }
 
-  protected GroupAuditEventInfo(Type type, AccountInfo user, Timestamp date) {
+  protected GroupAuditEventInfo(Type type, AccountInfo user, Optional<Timestamp> date) {
     this.type = type;
     this.user = user;
-    this.date = date;
+    this.date = date.orElse(null);
   }
 
   public static class UserMemberAuditEventInfo extends GroupAuditEventInfo {
     public AccountInfo member;
 
-    public UserMemberAuditEventInfo(
-        Type type, AccountInfo user, Timestamp date, AccountInfo member) {
+    private UserMemberAuditEventInfo(
+        Type type, AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
       super(type, user, date);
       this.member = member;
     }
@@ -67,8 +68,8 @@
   public static class GroupMemberAuditEventInfo extends GroupAuditEventInfo {
     public GroupInfo member;
 
-    public GroupMemberAuditEventInfo(
-        Type type, AccountInfo user, Timestamp date, GroupInfo member) {
+    private GroupMemberAuditEventInfo(
+        Type type, AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
       super(type, user, date);
       this.member = member;
     }
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index f0f5516..d827108 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -34,37 +34,35 @@
     return CommitInfoSubject::new;
   }
 
+  private final CommitInfo commitInfo;
+
   private CommitInfoSubject(FailureMetadata failureMetadata, CommitInfo commitInfo) {
     super(failureMetadata, commitInfo);
+    this.commitInfo = commitInfo;
   }
 
   public StringSubject commit() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("commit()").that(commitInfo.commit);
+    return check("commit").that(commitInfo.commit);
   }
 
   public ListSubject<CommitInfoSubject, CommitInfo> parents() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("parents()").about(elements()).thatCustom(commitInfo.parents, commits());
+    return check("parents").about(elements()).thatCustom(commitInfo.parents, commits());
   }
 
   public GitPersonSubject committer() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("committer()").about(gitPersons()).that(commitInfo.committer);
+    return check("committer").about(gitPersons()).that(commitInfo.committer);
   }
 
   public GitPersonSubject author() {
     isNotNull();
-    CommitInfo commitInfo = actual();
-    return check("author()").about(gitPersons()).that(commitInfo.author);
+    return check("author").about(gitPersons()).that(commitInfo.author);
   }
 
   public StringSubject message() {
     isNotNull();
-    CommitInfo commitInfo = actual();
     return check("message").that(commitInfo.message);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
index 25750c1..e7aa01d 100644
--- a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
@@ -37,13 +37,15 @@
     return ContentEntrySubject::new;
   }
 
+  private final ContentEntry contentEntry;
+
   private ContentEntrySubject(FailureMetadata failureMetadata, ContentEntry contentEntry) {
     super(failureMetadata, contentEntry);
+    this.contentEntry = contentEntry;
   }
 
   public void isDueToRebase() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     if (contentEntry.dueToRebase == null || !contentEntry.dueToRebase) {
       failWithActual(simpleFact("expected entry to be marked 'dueToRebase'"));
     }
@@ -51,7 +53,6 @@
 
   public void isNotDueToRebase() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     if (contentEntry.dueToRebase != null && contentEntry.dueToRebase) {
       failWithActual(simpleFact("expected entry not to be marked 'dueToRebase'"));
     }
@@ -59,7 +60,6 @@
 
   public ListSubject<StringSubject, String> commonLines() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("commonLines()")
         .about(elements())
         .that(contentEntry.ab, StandardSubjectBuilder::that);
@@ -67,31 +67,26 @@
 
   public ListSubject<StringSubject, String> linesOfA() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("linesOfA()").about(elements()).that(contentEntry.a, StandardSubjectBuilder::that);
   }
 
   public ListSubject<StringSubject, String> linesOfB() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("linesOfB()").about(elements()).that(contentEntry.b, StandardSubjectBuilder::that);
   }
 
   public IterableSubject intralineEditsOfA() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("intralineEditsOfA()").that(contentEntry.editA);
   }
 
   public IterableSubject intralineEditsOfB() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("intralineEditsOfB()").that(contentEntry.editB);
   }
 
   public IntegerSubject numberOfSkippedLines() {
     isNotNull();
-    ContentEntry contentEntry = actual();
     return check("numberOfSkippedLines()").that(contentEntry.skip);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index ee37bde..1322793 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -32,33 +32,32 @@
     return assertAbout(DiffInfoSubject::new).that(diffInfo);
   }
 
+  private final DiffInfo diffInfo;
+
   private DiffInfoSubject(FailureMetadata failureMetadata, DiffInfo diffInfo) {
     super(failureMetadata, diffInfo);
+    this.diffInfo = diffInfo;
   }
 
   public ListSubject<ContentEntrySubject, ContentEntry> content() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("content()")
+    return check("content")
         .about(elements())
         .thatCustom(diffInfo.content, ContentEntrySubject.contentEntries());
   }
 
   public ComparableSubject<?, ChangeType> changeType() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("changeType()").that(diffInfo.changeType);
+    return check("changeType").that(diffInfo.changeType);
   }
 
   public FileMetaSubject metaA() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("metaA()").about(fileMetas()).that(diffInfo.metaA);
+    return check("metaA").about(fileMetas()).that(diffInfo.metaA);
   }
 
   public FileMetaSubject metaB() {
     isNotNull();
-    DiffInfo diffInfo = actual();
-    return check("metaB()").about(fileMetas()).that(diffInfo.metaB);
+    return check("metaB").about(fileMetas()).that(diffInfo.metaB);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
index 1c99141..25db1fe 100644
--- a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
@@ -39,19 +39,20 @@
     return OptionalSubject.assertThat(editInfoOptional, edits());
   }
 
+  private final EditInfo editInfo;
+
   private EditInfoSubject(FailureMetadata failureMetadata, EditInfo editInfo) {
     super(failureMetadata, editInfo);
+    this.editInfo = editInfo;
   }
 
   public CommitInfoSubject commit() {
     isNotNull();
-    EditInfo editInfo = actual();
-    return check("commit()").about(commits()).that(editInfo.commit);
+    return check("commit").about(commits()).that(editInfo.commit);
   }
 
   public StringSubject baseRevision() {
     isNotNull();
-    EditInfo editInfo = actual();
-    return check("baseRevision()").that(editInfo.baseRevision);
+    return check("baseRevision").that(editInfo.baseRevision);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
index 3ebf838..27d3f0e 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
@@ -28,25 +28,25 @@
     return assertAbout(FileInfoSubject::new).that(fileInfo);
   }
 
+  private final FileInfo fileInfo;
+
   private FileInfoSubject(FailureMetadata failureMetadata, FileInfo fileInfo) {
     super(failureMetadata, fileInfo);
+    this.fileInfo = fileInfo;
   }
 
   public IntegerSubject linesInserted() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return check("linesInserted()").that(fileInfo.linesInserted);
+    return check("linesInserted").that(fileInfo.linesInserted);
   }
 
   public IntegerSubject linesDeleted() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return check("linesDeleted()").that(fileInfo.linesDeleted);
+    return check("linesDeleted").that(fileInfo.linesDeleted);
   }
 
   public ComparableSubject<?, Character> status() {
     isNotNull();
-    FileInfo fileInfo = actual();
-    return check("status()").that(fileInfo.status);
+    return check("status").that(fileInfo.status);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
index d1b2031..6cac80dd 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
@@ -31,13 +31,15 @@
     return FileMetaSubject::new;
   }
 
+  private final FileMeta fileMeta;
+
   private FileMetaSubject(FailureMetadata failureMetadata, FileMeta fileMeta) {
     super(failureMetadata, fileMeta);
+    this.fileMeta = fileMeta;
   }
 
   public IntegerSubject totalLineCount() {
     isNotNull();
-    FileMeta fileMeta = actual();
     return check("totalLineCount()").that(fileMeta.lines);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
index 6ba5f8b..1ecc604 100644
--- a/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
@@ -33,23 +33,26 @@
     return FixReplacementInfoSubject::new;
   }
 
+  private final FixReplacementInfo fixReplacementInfo;
+
   private FixReplacementInfoSubject(
       FailureMetadata failureMetadata, FixReplacementInfo fixReplacementInfo) {
     super(failureMetadata, fixReplacementInfo);
+    this.fixReplacementInfo = fixReplacementInfo;
   }
 
   public StringSubject path() {
     isNotNull();
-    return check("path()").that(actual().path);
+    return check("path").that(fixReplacementInfo.path);
   }
 
   public RangeSubject range() {
     isNotNull();
-    return check("range()").about(ranges()).that(actual().range);
+    return check("range").about(ranges()).that(fixReplacementInfo.range);
   }
 
   public StringSubject replacement() {
     isNotNull();
-    return check("replacement()").that(actual().replacement);
+    return check("replacement").that(fixReplacementInfo.replacement);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
index 98dac38..1e95907 100644
--- a/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
@@ -21,7 +21,6 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.truth.ListSubject;
@@ -36,20 +35,23 @@
     return FixSuggestionInfoSubject::new;
   }
 
+  private final FixSuggestionInfo fixSuggestionInfo;
+
   private FixSuggestionInfoSubject(
       FailureMetadata failureMetadata, FixSuggestionInfo fixSuggestionInfo) {
     super(failureMetadata, fixSuggestionInfo);
+    this.fixSuggestionInfo = fixSuggestionInfo;
   }
 
   public StringSubject fixId() {
-    return Truth.assertThat(actual().fixId).named("fixId");
+    return check("fixId").that(fixSuggestionInfo.fixId);
   }
 
   public ListSubject<FixReplacementInfoSubject, FixReplacementInfo> replacements() {
     isNotNull();
-    return check("replacements()")
+    return check("replacements")
         .about(elements())
-        .thatCustom(actual().replacements, fixReplacements());
+        .thatCustom(fixSuggestionInfo.replacements, fixReplacements());
   }
 
   public FixReplacementInfoSubject onlyReplacement() {
@@ -58,6 +60,6 @@
 
   public StringSubject description() {
     isNotNull();
-    return check("description()").that(actual().description);
+    return check("description").that(fixSuggestionInfo.description);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index dee0636..c9f5a79 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.extensions.common.testing;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertAbout;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
@@ -37,36 +37,35 @@
     return GitPersonSubject::new;
   }
 
+  private final GitPerson gitPerson;
+
   private GitPersonSubject(FailureMetadata failureMetadata, GitPerson gitPerson) {
     super(failureMetadata, gitPerson);
+    this.gitPerson = gitPerson;
   }
 
   public StringSubject name() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("name()").that(gitPerson.name);
+    return check("name").that(gitPerson.name);
   }
 
   public StringSubject email() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("email()").that(gitPerson.email);
+    return check("email").that(gitPerson.email);
   }
 
   public ComparableSubject<?, Timestamp> date() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("date()").that(gitPerson.date);
+    return check("date").that(gitPerson.date);
   }
 
   public IntegerSubject tz() {
     isNotNull();
-    GitPerson gitPerson = actual();
-    return check("tz()").that(gitPerson.tz);
+    return check("tz").that(gitPerson.tz);
   }
 
   public void hasSameDateAs(GitPerson other) {
-    checkNotNull(other, "'other' GitPerson must not be null");
+    requireNonNull(other, "'other' GitPerson must not be null");
     isNotNull();
     date().isEqualTo(other.date);
     tz().isEqualTo(other.tz);
@@ -76,7 +75,7 @@
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(new Date(actual().date.getTime())).isEqualTo(ident.getWhen());
+    check("roundedDate()").that(new Date(gitPerson.date.getTime())).isEqualTo(ident.getWhen());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
index 12acb8d..0d049e0 100644
--- a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
@@ -32,36 +32,39 @@
     return RangeSubject::new;
   }
 
+  private final Comment.Range range;
+
   private RangeSubject(FailureMetadata failureMetadata, Comment.Range range) {
     super(failureMetadata, range);
+    this.range = range;
   }
 
   public IntegerSubject startLine() {
-    return check("startLine()").that(actual().startLine);
+    return check("startLine").that(range.startLine);
   }
 
   public IntegerSubject startCharacter() {
-    return check("startCharacter()").that(actual().startCharacter);
+    return check("startCharacter").that(range.startCharacter);
   }
 
   public IntegerSubject endLine() {
-    return check("endLine()").that(actual().endLine);
+    return check("endLine").that(range.endLine);
   }
 
   public IntegerSubject endCharacter() {
-    return check("endCharacter()").that(actual().endCharacter);
+    return check("endCharacter").that(range.endCharacter);
   }
 
   public void isValid() {
     isNotNull();
-    if (!actual().isValid()) {
+    if (!range.isValid()) {
       failWithActual(simpleFact("expected to be valid"));
     }
   }
 
   public void isInvalid() {
     isNotNull();
-    if (actual().isValid()) {
+    if (range.isValid()) {
       failWithActual(simpleFact("expected to be invalid"));
     }
   }
diff --git a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
index 033f54b..0a53154 100644
--- a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
@@ -28,7 +28,7 @@
 
   public static ListSubject<RobotCommentInfoSubject, RobotCommentInfo> assertThatList(
       List<RobotCommentInfo> robotCommentInfos) {
-    return ListSubject.assertThat(robotCommentInfos, robotComments()).named("robotCommentInfos");
+    return ListSubject.assertThat(robotCommentInfos, robotComments());
   }
 
   public static RobotCommentInfoSubject assertThat(RobotCommentInfo robotCommentInfo) {
@@ -39,15 +39,18 @@
     return RobotCommentInfoSubject::new;
   }
 
+  private final RobotCommentInfo robotCommentInfo;
+
   private RobotCommentInfoSubject(
       FailureMetadata failureMetadata, RobotCommentInfo robotCommentInfo) {
     super(failureMetadata, robotCommentInfo);
+    this.robotCommentInfo = robotCommentInfo;
   }
 
   public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
-    return check("fixSuggestions()")
+    return check("fixSuggestions")
         .about(elements())
-        .thatCustom(actual().fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
+        .thatCustom(robotCommentInfo.fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
   }
 
   public FixSuggestionInfoSubject onlyFixSuggestion() {
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
index 5109205..d492aa2 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
+++ b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
@@ -41,8 +41,11 @@
     return OptionalSubject.assertThat(binaryResultOptional, binaryResults());
   }
 
+  private final BinaryResult binaryResult;
+
   private BinaryResultSubject(FailureMetadata failureMetadata, BinaryResult binaryResult) {
     super(failureMetadata, binaryResult);
+    this.binaryResult = binaryResult;
   }
 
   public StringSubject asString() throws IOException {
@@ -50,7 +53,6 @@
     // 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();
     return check("asString()").that(binaryResult.asString());
   }
 
@@ -59,7 +61,6 @@
     // 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();
diff --git a/java/com/google/gerrit/git/BUILD b/java/com/google/gerrit/git/BUILD
index f0c01de..fc146dc 100644
--- a/java/com/google/gerrit/git/BUILD
+++ b/java/com/google/gerrit/git/BUILD
@@ -3,6 +3,7 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib/jgit/org.eclipse.jgit:jgit",
     ],
diff --git a/java/com/google/gerrit/git/ObjectIds.java b/java/com/google/gerrit/git/ObjectIds.java
new file mode 100644
index 0000000..4d83046
--- /dev/null
+++ b/java/com/google/gerrit/git/ObjectIds.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+
+/** Static utilities for working with {@code ObjectId}s. */
+public class ObjectIds {
+  /** Length of a binary SHA-1 byte array. */
+  public static final int LEN = Constants.OBJECT_ID_LENGTH;
+
+  /** Length of a hex SHA-1 string. */
+  public static final int STR_LEN = Constants.OBJECT_ID_STRING_LENGTH;
+
+  /** Default abbreviated length of a hex SHA-1 string. */
+  public static final int ABBREV_STR_LEN = 7;
+
+  /**
+   * Abbreviate an ID's hex string representation to 7 chars.
+   *
+   * @param id object ID.
+   * @return abbreviated hex string representation, exactly 7 chars.
+   */
+  public static String abbreviateName(AnyObjectId id) {
+    return abbreviateName(id, ABBREV_STR_LEN);
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation to {@code n} chars.
+   *
+   * @param id object ID.
+   * @param n number of hex chars, 1 to 40.
+   * @return abbreviated hex string representation, exactly {@code n} chars.
+   */
+  public static String abbreviateName(AnyObjectId id, int n) {
+    checkValidLength(n);
+    return requireNonNull(id).abbreviate(n).name();
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation uniquely to at least 7 chars.
+   *
+   * @param id object ID.
+   * @param reader object reader for determining uniqueness.
+   * @return abbreviated hex string representation, unique according to {@code reader} at least 7
+   *     chars.
+   * @throws IOException if an error occurs while looking for ambiguous objects.
+   */
+  public static String abbreviateName(AnyObjectId id, ObjectReader reader) throws IOException {
+    return abbreviateName(id, ABBREV_STR_LEN, reader);
+  }
+
+  /**
+   * Abbreviate an ID's hex string representation uniquely to at least {@code n} chars.
+   *
+   * @param id object ID.
+   * @param n minimum number of hex chars, 1 to 40.
+   * @param reader object reader for determining uniqueness.
+   * @return abbreviated hex string representation, unique according to {@code reader} at least
+   *     {@code n} chars.
+   * @throws IOException if an error occurs while looking for ambiguous objects.
+   */
+  public static String abbreviateName(AnyObjectId id, int n, ObjectReader reader)
+      throws IOException {
+    checkValidLength(n);
+    return reader.abbreviate(id, n).name();
+  }
+
+  /**
+   * Copy a nullable ID, preserving null.
+   *
+   * @param id object ID.
+   * @return {@link AnyObjectId#copy} of {@code id}, or {@code null} if {@code id} is null.
+   */
+  @Nullable
+  public static ObjectId copyOrNull(@Nullable AnyObjectId id) {
+    return id != null ? id.copy() : null;
+  }
+
+  /**
+   * Copy a nullable ID, converting null to {@code zeroId}.
+   *
+   * @param id object ID.
+   * @return {@link AnyObjectId#copy} of {@code id}, or {@link ObjectId#zeroId} if {@code id} is
+   *     null.
+   */
+  public static ObjectId copyOrZero(@Nullable AnyObjectId id) {
+    return id != null ? id.copy() : ObjectId.zeroId();
+  }
+
+  /**
+   * Return whether the given ID matches the given abbreviation.
+   *
+   * @param id object ID.
+   * @param abbreviation abbreviated hex object ID. May not be null, but may be an invalid hex SHA-1
+   *     abbreviation string.
+   * @return true if {@code id} is not null and {@code abbreviation} is a valid hex SHA-1
+   *     abbreviation that matches {@code id}, false otherwise.
+   */
+  public static boolean matchesAbbreviation(@Nullable AnyObjectId id, String abbreviation) {
+    requireNonNull(abbreviation);
+    return id != null && id.name().startsWith(abbreviation);
+  }
+
+  private static void checkValidLength(int n) {
+    checkArgument(n > 0);
+    checkArgument(n <= STR_LEN);
+  }
+
+  private ObjectIds() {}
+}
diff --git a/java/com/google/gerrit/git/testing/CommitSubject.java b/java/com/google/gerrit/git/testing/CommitSubject.java
index 0873107..4d02313 100644
--- a/java/com/google/gerrit/git/testing/CommitSubject.java
+++ b/java/com/google/gerrit/git/testing/CommitSubject.java
@@ -56,8 +56,11 @@
     commitSubject.hasSha1(expectedSha1);
   }
 
-  private CommitSubject(FailureMetadata metadata, RevCommit actual) {
-    super(metadata, actual);
+  private final RevCommit commit;
+
+  private CommitSubject(FailureMetadata metadata, RevCommit commit) {
+    super(metadata, commit);
+    this.commit = commit;
   }
 
   /**
@@ -67,8 +70,7 @@
    */
   public void hasCommitMessage(String expectedCommitMessage) {
     isNotNull();
-    RevCommit commit = actual();
-    check("commitMessage()").that(commit.getFullMessage()).isEqualTo(expectedCommitMessage);
+    check("getFullMessage()").that(commit.getFullMessage()).isEqualTo(expectedCommitMessage);
   }
 
   /**
@@ -78,7 +80,6 @@
    */
   public void hasCommitTimestamp(Timestamp expectedCommitTimestamp) {
     isNotNull();
-    RevCommit commit = actual();
     long timestampDiffMs =
         Math.abs(commit.getCommitTime() * 1000L - expectedCommitTimestamp.getTime());
     check("commitTimestampDiff()").that(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
@@ -91,7 +92,6 @@
    */
   public void hasSha1(ObjectId expectedSha1) {
     isNotNull();
-    RevCommit commit = actual();
     check("sha1()").that(commit).isEqualTo(expectedSha1);
   }
 }
diff --git a/java/com/google/gerrit/git/testing/ObjectIdSubject.java b/java/com/google/gerrit/git/testing/ObjectIdSubject.java
index 5fe91f9..5a99229 100644
--- a/java/com/google/gerrit/git/testing/ObjectIdSubject.java
+++ b/java/com/google/gerrit/git/testing/ObjectIdSubject.java
@@ -29,13 +29,15 @@
     return ObjectIdSubject::new;
   }
 
-  private ObjectIdSubject(FailureMetadata metadata, ObjectId actual) {
-    super(metadata, actual);
+  private final ObjectId objectId;
+
+  private ObjectIdSubject(FailureMetadata metadata, ObjectId objectId) {
+    super(metadata, objectId);
+    this.objectId = objectId;
   }
 
   public void hasName(String expectedName) {
     isNotNull();
-    ObjectId objectId = actual();
-    check("name()").that(objectId.getName()).isEqualTo(expectedName);
+    check("getName()").that(objectId.getName()).isEqualTo(expectedName);
   }
 }
diff --git a/java/com/google/gerrit/git/testing/PushResultSubject.java b/java/com/google/gerrit/git/testing/PushResultSubject.java
index f5c9810..9ff4c3b 100644
--- a/java/com/google/gerrit/git/testing/PushResultSubject.java
+++ b/java/com/google/gerrit/git/testing/PushResultSubject.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.git.testing;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Fact.fact;
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.git.testing.PushResultSubject.RemoteRefUpdateSubject.refs;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
@@ -26,7 +28,6 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StreamSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth;
 import com.google.gerrit.common.Nullable;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -36,32 +37,35 @@
     return assertAbout(PushResultSubject::new).that(actual);
   }
 
-  private PushResultSubject(FailureMetadata metadata, PushResult actual) {
-    super(metadata, actual);
+  private final PushResult pushResult;
+
+  private PushResultSubject(FailureMetadata metadata, PushResult pushResult) {
+    super(metadata, pushResult);
+    this.pushResult = pushResult;
   }
 
   public void hasNoMessages() {
-    check("hasNoMessages()")
-        .withMessage("expected no messages")
-        .that(Strings.nullToEmpty(trimMessages()))
-        .isEqualTo("");
+    isNotNull();
+    check("hasNoMessages()").that(Strings.nullToEmpty(getTrimmedMessages())).isEqualTo("");
   }
 
   public void hasMessages(String... expectedLines) {
     checkArgument(expectedLines.length > 0, "use hasNoMessages()");
     isNotNull();
-    check("messages()").that(trimMessages()).isEqualTo(String.join("\n", expectedLines));
+    check("getTrimmedMessages()")
+        .that(getTrimmedMessages())
+        .isEqualTo(String.join("\n", expectedLines));
   }
 
   public void containsMessages(String... expectedLines) {
     checkArgument(expectedLines.length > 0, "use hasNoMessages()");
     isNotNull();
-    Iterable<String> got = Splitter.on("\n").split(trimMessages());
-    check("messages()").that(got).containsAtLeastElementsIn(expectedLines).inOrder();
+    Iterable<String> got = Splitter.on("\n").split(getTrimmedMessages());
+    check("getTrimmedMessages()").that(got).containsAtLeastElementsIn(expectedLines).inOrder();
   }
 
-  private String trimMessages() {
-    return trimMessages(actual().getMessages());
+  private String getTrimmedMessages() {
+    return trimMessages(pushResult.getMessages());
   }
 
   @VisibleForTesting
@@ -78,15 +82,16 @@
   }
 
   public void hasProcessed(ImmutableMap<String, Integer> expected) {
+    isNotNull();
     ImmutableMap<String, Integer> actual;
-    String messages = actual().getMessages();
+    String messages = pushResult.getMessages();
     try {
       actual = parseProcessed(messages);
     } catch (RuntimeException e) {
-      Truth.assert_()
-          .fail(
-              "failed to parse \"Processing changes\" line from messages: %s\n%s",
-              messages, Throwables.getStackTraceAsString(e));
+      failWithActual(
+          fact(
+              "failed to parse \"Processing changes\" line from messages, reason:",
+              Throwables.getStackTraceAsString(e)));
       return;
     }
     check("processedCommands()").that(actual).containsExactlyEntriesIn(expected).inOrder();
@@ -119,57 +124,61 @@
   }
 
   public RemoteRefUpdateSubject ref(String refName) {
-    return assertAbout(
-            (FailureMetadata m, RemoteRefUpdate a) -> new RemoteRefUpdateSubject(refName, m, a))
-        .that(actual().getRemoteUpdate(refName));
+    isNotNull();
+    return check("getRemoteUpdate(%s)", refName)
+        .about(refs())
+        .that(pushResult.getRemoteUpdate(refName));
   }
 
   public RemoteRefUpdateSubject onlyRef(String refName) {
+    isNotNull();
     check("setOfRefs()")
         .about(StreamSubject.streams())
-        .that(actual().getRemoteUpdates().stream().map(RemoteRefUpdate::getRemoteName))
-        .named("set of refs")
+        .that(pushResult.getRemoteUpdates().stream().map(RemoteRefUpdate::getRemoteName))
         .containsExactly(refName);
     return ref(refName);
   }
 
   public static class RemoteRefUpdateSubject
       extends Subject<RemoteRefUpdateSubject, RemoteRefUpdate> {
-    private final String refName;
+    private final RemoteRefUpdate remoteRefUpdate;
 
-    private RemoteRefUpdateSubject(
-        String refName, FailureMetadata metadata, RemoteRefUpdate actual) {
-      super(metadata, actual);
-      this.refName = refName;
-      named("ref update for %s", refName).isNotNull();
+    private RemoteRefUpdateSubject(FailureMetadata metadata, RemoteRefUpdate remoteRefUpdate) {
+      super(metadata, remoteRefUpdate);
+      this.remoteRefUpdate = remoteRefUpdate;
+    }
+
+    static Factory<RemoteRefUpdateSubject, RemoteRefUpdate> refs() {
+      return RemoteRefUpdateSubject::new;
     }
 
     public void hasStatus(RemoteRefUpdate.Status status) {
-      RemoteRefUpdate u = actual();
-      Truth.assertThat(u.getStatus())
-          .named(
-              "status of ref update for %s%s",
-              refName, u.getMessage() != null ? ": " + u.getMessage() : "")
+      isNotNull();
+      RemoteRefUpdate u = remoteRefUpdate;
+      check("getStatus()")
+          .withMessage(
+              "status message: %s", u.getMessage() != null ? ": " + u.getMessage() : "<emtpy>")
+          .that(u.getStatus())
           .isEqualTo(status);
     }
 
     public void hasNoMessage() {
-      Truth.assertThat(actual().getMessage())
-          .named("message of ref update for %s", refName)
-          .isNull();
+      isNotNull();
+      check("getMessage()").that(remoteRefUpdate.getMessage()).isNull();
     }
 
     public void hasMessage(String expected) {
-      Truth.assertThat(actual().getMessage())
-          .named("message of ref update for %s", refName)
-          .isEqualTo(expected);
+      isNotNull();
+      check("getMessage()").that(remoteRefUpdate.getMessage()).isEqualTo(expected);
     }
 
     public void isOk() {
+      isNotNull();
       hasStatus(RemoteRefUpdate.Status.OK);
     }
 
     public void isRejected(String expectedMessage) {
+      isNotNull();
       hasStatus(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
       hasMessage(expectedMessage);
     }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 06e44d6..49806cf 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 7dd01d9..519c400 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -16,10 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.base.Preconditions;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -45,7 +47,6 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -75,9 +76,6 @@
  * after checking with a {@link PublicKeyChecker}.
  */
 public class PublicKeyStore implements AutoCloseable {
-  private static final ObjectId EMPTY_TREE =
-      ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904");
-
   /** Ref where GPG public keys are stored. */
   public static final String REFS_GPG_KEYS = "refs/meta/gpg-keys";
 
@@ -360,7 +358,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_ID)) {
         return RefUpdate.Result.NO_CHANGE;
       }
 
@@ -516,7 +514,7 @@
   }
 
   static ObjectId keyObjectId(long keyId) {
-    byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+    byte[] buf = new byte[ObjectIds.LEN];
     NB.encodeInt64(buf, 0, keyId);
     return ObjectId.fromRaw(buf);
   }
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index f641902..9752b54 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -18,8 +18,10 @@
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
@@ -27,12 +29,15 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 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.extensions.restapi.RestModifyView;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.gpg.CheckResult;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
@@ -50,6 +55,8 @@
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -83,6 +90,7 @@
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final RetryHelper retryHelper;
 
   @Inject
   PostGpgKeys(
@@ -93,7 +101,8 @@
       AddKeySender.Factory addKeyFactory,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
-      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      RetryHelper retryHelper) {
     this.serverIdent = serverIdent;
     this.self = self;
     this.storeProvider = storeProvider;
@@ -102,12 +111,12 @@
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.retryHelper = retryHelper;
   }
 
   @Override
   public Map<String, GpgKeyInfo> apply(AccountResource rsrc, GpgKeysInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
-          PGPException, IOException, ConfigInvalidException {
+      throws RestApiException, PGPException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
 
     Collection<ExternalId> existingExtIds =
@@ -190,7 +199,24 @@
 
   private void storeKeys(
       AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
-      throws BadRequestException, ResourceConflictException, PGPException, IOException {
+      throws RestApiException, PGPException, IOException {
+    try {
+      retryHelper.execute(
+          ActionType.ACCOUNT_UPDATE,
+          () -> tryStoreKeys(rsrc, keyRings, toRemove),
+          LockFailureException.class::isInstance);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      Throwables.throwIfInstanceOf(e, PGPException.class);
+      throw new StorageException(e);
+    }
+  }
+
+  private Void tryStoreKeys(
+      AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
+      throws RestApiException, PGPException, IOException {
     try (PublicKeyStore store = storeProvider.get()) {
       List<String> addedKeys = new ArrayList<>();
       IdentifiedUser user = rsrc.getUser();
@@ -241,10 +267,10 @@
         case REJECTED_MISSING_OBJECT:
         case REJECTED_OTHER_REASON:
         default:
-          // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
           throw new ResourceConflictException("Failed to save public keys: " + saveResult);
       }
     }
+    return null;
   }
 
   private ExternalId.Key toExtIdKey(byte[] fp) {
@@ -259,12 +285,12 @@
     }
 
     if (accountStates.size() > 1) {
-      StringBuilder msg = new StringBuilder();
-      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());
+      String msg = "GPG key " + extIdKey.get() + " associated with multiple accounts: [";
+      msg =
+          accountStates.stream()
+              .map(a -> a.getAccount().getId().toString())
+              .collect(joining(", ", msg, "]"));
+      throw new IllegalStateException(msg);
     }
 
     return accountStates.get(0).getAccount();
diff --git a/java/com/google/gerrit/httpd/DirectChangeByCommit.java b/java/com/google/gerrit/httpd/DirectChangeByCommit.java
index 152a83d..f86c240 100644
--- a/java/com/google/gerrit/httpd/DirectChangeByCommit.java
+++ b/java/com/google/gerrit/httpd/DirectChangeByCommit.java
@@ -47,7 +47,7 @@
       // If exactly one change matches, link to that change.
       // TODO Link to a specific patch set, if one matched.
       ChangeInfo ci = results.iterator().next();
-      token = PageLinks.toChange(new Project.NameKey(ci.project), new Change.Id(ci._number));
+      token = PageLinks.toChange(Project.nameKey(ci.project), Change.id(ci._number));
     } else {
       // Otherwise, link to the query page.
       token = PageLinks.toChangeQuery(query);
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index c97ee10..23afbd3 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -279,7 +279,7 @@
       user.setAccessPath(AccessPath.GIT);
 
       try {
-        Project.NameKey nameKey = new Project.NameKey(projectName);
+        Project.NameKey nameKey = Project.nameKey(projectName);
         ProjectState state = projectCache.checkedGet(nameKey);
         if (state == null || !state.statePermitsRead()) {
           throw new RepositoryNotFoundException(nameKey.get());
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index d53a5c5..e1b983c 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -61,7 +61,7 @@
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
     } catch (PermissionBackendException | RuntimeException e) {
-      throw new IOException("Unable to lookup change " + id.id, e);
+      throw new IOException("Unable to lookup change " + id.get(), e);
     }
     String path =
         PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 993a042..fe7d72d 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -147,7 +147,7 @@
             while (name.endsWith("/")) {
               name = name.substring(0, name.length() - 1);
             }
-            Project.NameKey project = new Project.NameKey(name);
+            Project.NameKey project = Project.nameKey(name);
             toGerrit(
                 PageLinks.toChangeQuery(PageLinks.projectQuery(project, Change.Status.NEW)),
                 req,
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index d09b4dd..d7c41bf 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -286,7 +286,7 @@
           case 0:
             break PARSE;
           case 1:
-            accountId = new Account.Id(readVarInt32(in));
+            accountId = Account.id(readVarInt32(in));
             continue;
           case 2:
             refreshCookieAt = readFixInt64(in);
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index f09f93d..edd12cc 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -16,7 +16,6 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/audit",
         "//lib:guava",
-        "//java/com/google/gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/commons:codec",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index 28256cf..90a22ac 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.httpd.ProxyProperties;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.KeyUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 6cb094f..b5d9f29 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -174,10 +174,8 @@
     }
     Path myconf = Files.createTempFile(site.tmp_dir, "gitweb_config", ".perl");
 
-    // To make our configuration file only readable or writable by us;
-    // this reduces the chances of someone tampering with the file.
-    //
-    // TODO(dborowitz): Is there a portable way to do this with NIO?
+    // To make our configuration file only readable or writable by us; this reduces the chances of
+    // someone tampering with the file.
     File myconfFile = myconf.toFile();
     myconfFile.setWritable(false, false /* all */);
     myconfFile.setReadable(false, false /* all */);
@@ -414,7 +412,7 @@
       name = name.substring(0, name.length() - 4);
     }
 
-    Project.NameKey nameKey = new Project.NameKey(name);
+    Project.NameKey nameKey = Project.nameKey(name);
     ProjectState projectState;
     try {
       projectState = projectCache.checkedGet(nameKey);
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 391e20f..2a0555e 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
@@ -70,6 +71,7 @@
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
@@ -259,6 +261,13 @@
 
     Module configModule = new GerritServerConfigModule();
     modules.add(configModule);
+    modules.add(
+        new LifecycleModule() {
+          @Override
+          protected void configure() {
+            listener().to(SystemReaderInstaller.class);
+          }
+        });
     modules.add(new DropWizardMetricMaker.ApiModule());
     return Guice.createInjector(PRODUCTION, modules);
   }
diff --git a/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
index 1d0e7d8b..a4538cf 100644
--- a/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -118,13 +118,13 @@
       }
     }
 
-    final Change.Id changeId = patchKey.getParentKey().getParentKey();
+    final Change.Id changeId = patchKey.patchSetId().changeId();
     String revision;
     try {
       ChangeNotes notes = changeNotesFactory.createChecked(changeId);
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
       projectCache.checkedGet(notes.getProjectName()).checkStatePermitsRead();
-      if (patchKey.getParentKey().get() == 0) {
+      if (patchKey.patchSetId().get() == 0) {
         // change edit
         Optional<ChangeEdit> edit = changeEditUtil.byChange(notes);
         if (edit.isPresent()) {
@@ -134,12 +134,12 @@
           return;
         }
       } else {
-        PatchSet patchSet = psUtil.get(notes, patchKey.getParentKey());
+        PatchSet patchSet = psUtil.get(notes, patchKey.patchSetId());
         if (patchSet == null) {
           rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           return;
         }
-        revision = patchSet.getRevision().get();
+        revision = patchSet.commitId().name();
       }
     } catch (ResourceConflictException | NoSuchChangeException | AuthException e) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
@@ -150,7 +150,7 @@
       return;
     }
 
-    String path = patchKey.getFileName();
+    String path = patchKey.fileName();
     String restUrl =
         String.format(
             "%s/changes/%d/revisions/%s/files/%s/download?parent=%d",
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index e513620..cf21fcd 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -60,7 +60,6 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   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}.
@@ -78,11 +77,6 @@
           "/dashboard/*",
           "/settings/*",
           "/Documentation/q/*");
-  // 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.
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index d6dca48..33daf46 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -601,11 +601,13 @@
                 e.caching(),
                 e);
       } catch (NotImplementedException e) {
+        logger.atSevere().withCause(e).log("Error in %s %s", req.getMethod(), uriForLogging(req));
         responseBytes =
             replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
       } catch (UpdateException e) {
         Throwable t = e.getCause();
         if (t instanceof LockFailureException) {
+          logger.atSevere().withCause(t).log("Error in %s %s", req.getMethod(), uriForLogging(req));
           responseBytes =
               replyError(
                   req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
@@ -1431,11 +1433,7 @@
 
   private static long handleException(
       Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException {
-    String uri = req.getRequestURI();
-    if (!Strings.isNullOrEmpty(req.getQueryString())) {
-      uri += "?" + LogRedactUtil.redactQueryString(req.getQueryString());
-    }
-    logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uri);
+    logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uriForLogging(req));
     if (!res.isCommitted()) {
       res.reset();
       return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
@@ -1443,6 +1441,14 @@
     return 0;
   }
 
+  private static String uriForLogging(HttpServletRequest req) {
+    String uri = req.getRequestURI();
+    if (!Strings.isNullOrEmpty(req.getQueryString())) {
+      uri += "?" + LogRedactUtil.redactQueryString(req.getQueryString());
+    }
+    return uri;
+  }
+
   public static long replyError(
       HttpServletRequest req,
       HttpServletResponse res,
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index dfdc014..7fcf342 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -22,6 +22,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
diff --git a/java/com/google/gerrit/index/RefState.java b/java/com/google/gerrit/index/RefState.java
index f0e465d..dd8bcfa 100644
--- a/java/com/google/gerrit/index/RefState.java
+++ b/java/com/google/gerrit/index/RefState.java
@@ -23,10 +23,10 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -42,7 +42,7 @@
       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)));
+      result.put(Project.nameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
     }
     return result;
   }
@@ -61,7 +61,7 @@
 
   public byte[] toByteArray(Project.NameKey project) {
     byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
-    byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
+    byte[] b = new byte[a.length + ObjectIds.STR_LEN];
     System.arraycopy(a, 0, b, 0, a.length);
     id().copyTo(b, a.length);
     return b;
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index e633bfa..ed7ae30 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -26,6 +25,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 
 /** Specific version of a secondary index schema. */
@@ -191,7 +191,7 @@
                 return new Values<>(f, Collections.singleton(v));
               }
             })
-        .filter(Predicates.notNull());
+        .filter(Objects::nonNull);
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/query/testing/TreeSubject.java b/java/com/google/gerrit/index/query/testing/TreeSubject.java
index c60b363..46c3895 100644
--- a/java/com/google/gerrit/index/query/testing/TreeSubject.java
+++ b/java/com/google/gerrit/index/query/testing/TreeSubject.java
@@ -28,38 +28,41 @@
     return assertAbout(TreeSubject::new).that(actual);
   }
 
+  private final Tree tree;
+
   private TreeSubject(FailureMetadata failureMetadata, Tree tree) {
     super(failureMetadata, tree);
+    this.tree = tree;
   }
 
   public void hasType(int expectedType) {
     isNotNull();
-    check("getType()").that(typeName(actual().getType())).isEqualTo(typeName(expectedType));
+    check("getType()").that(typeName(tree.getType())).isEqualTo(typeName(expectedType));
   }
 
   public void hasText(String expectedText) {
     requireNonNull(expectedText);
     isNotNull();
-    check("getText()").that(actual().getText()).isEqualTo(expectedText);
+    check("getText()").that(tree.getText()).isEqualTo(expectedText);
   }
 
   public void hasNoChildren() {
     isNotNull();
-    check("getChildCount()").that(actual().getChildCount()).isEqualTo(0);
+    check("getChildCount()").that(tree.getChildCount()).isEqualTo(0);
   }
 
   public void hasChildCount(int expectedChildCount) {
     checkArgument(
         expectedChildCount > 0, "expected child count must be positive: %s", expectedChildCount);
     isNotNull();
-    check("getChildCount()").that(actual().getChildCount()).isEqualTo(expectedChildCount);
+    check("getChildCount()").that(tree.getChildCount()).isEqualTo(expectedChildCount);
   }
 
   public TreeSubject child(int childIndex) {
     isNotNull();
     return check("getChild(%s)", childIndex)
         .about(TreeSubject::new)
-        .that(actual().getChild(childIndex));
+        .that(tree.getChild(childIndex));
   }
 
   private static String typeName(int type) {
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 0b787b6..41d16aa 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -154,7 +154,7 @@
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    Account.Id id = new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
+    Account.Id id = Account.id(doc.getField(ID.getName()).numericValue().intValue());
     // Use the AccountCache rather than depending on any stored fields in the document (of which
     // there shouldn't be any). The most expensive part to compute anyway is the effective group
     // IDs, and we don't have a good way to reindex when those change.
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 8bf0b6b..5ad5dfd 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -24,7 +24,6 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
@@ -77,6 +76,7 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexableField;
@@ -259,10 +259,6 @@
         new SortField(ID_SORT_FIELD, SortField.Type.LONG, true));
   }
 
-  public ChangeSubIndex getClosedChangesIndex() {
-    return closedIndex;
-  }
-
   private class QuerySource implements ChangeDataSource {
     private final List<ChangeSubIndex> indexes;
     private final Predicate<ChangeData> predicate;
@@ -450,10 +446,10 @@
       cd = changeDataFactory.create(parseProtoFrom(proto, ChangeProtoConverter.INSTANCE));
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
-      Change.Id id = new Change.Id(f.numericValue().intValue());
+      Change.Id id = Change.id(f.numericValue().intValue());
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(new Project.NameKey(project.stringValue()), id);
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
     // Any decoding that is done here must also be done in {@link ElasticChangeIndex}.
@@ -556,7 +552,7 @@
         if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
           break;
         }
-        accounts.add(new Account.Id(id));
+        accounts.add(Account.id(id));
       }
       cd.setReviewedBy(accounts);
     }
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 0fdef77..aab35d4 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -139,7 +139,7 @@
 
   @Override
   protected InternalGroup fromDocument(Document doc) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
+    AccountGroup.UUID uuid = 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).orElse(null);
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 950e206..44d7610 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -139,7 +139,7 @@
 
   @Override
   protected ProjectData fromDocument(Document doc) {
-    Project.NameKey nameKey = new Project.NameKey(doc.getField(NAME.getName()).stringValue());
+    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
     ProjectState projectState = projectCache.get().get(nameKey);
     return projectState == null ? null : projectState.toProjectData();
   }
diff --git a/java/com/google/gerrit/metrics/Field.java b/java/com/google/gerrit/metrics/Field.java
index 95eb9cf..b2acce7 100644
--- a/java/com/google/gerrit/metrics/Field.java
+++ b/java/com/google/gerrit/metrics/Field.java
@@ -16,8 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
-import com.google.common.base.Functions;
+import java.util.function.Function;
 
 /**
  * Describes a bucketing field used by a metric.
@@ -156,12 +155,11 @@
     return formatter;
   }
 
-  @SuppressWarnings("unchecked")
   private static <T> Function<T, String> initFormatter(Class<T> keyType) {
     if (keyType == String.class) {
-      return (Function<T, String>) Functions.<String>identity();
+      return s -> (String) s;
     } else if (keyType == Integer.class || keyType == Boolean.class) {
-      return (Function<T, String>) Functions.toStringFunction();
+      return Object::toString;
     } else if (Enum.class.isAssignableFrom(keyType)) {
       return in -> ((Enum<?>) in).name();
     }
diff --git a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
index 6d1daf4..d718035 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
@@ -15,10 +15,10 @@
 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 java.util.function.Function;
 
 /** Optimized version of {@link BucketedCallback} for single dimension. */
 class CallbackMetricImpl1<F1, V> extends BucketedCallback<V> {
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
index 46434ce..0e554a8 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedCounter} for single dimension. */
 class CounterImpl1<F1> extends BucketedCounter {
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
index 38c31a1..07afc2a 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Counter2;
 import com.google.gerrit.metrics.Counter3;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional counter metrics. */
 class CounterImplN extends BucketedCounter implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
index 3eb12fa..4578db1 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Histogram1;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedHistogram} for single dimension. */
 class HistogramImpl1<F1> extends BucketedHistogram implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
index 3561c55a..446590c 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Histogram2;
 import com.google.gerrit.metrics.Histogram3;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional Histogram metrics. */
 class HistogramImplN extends BucketedHistogram implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index 20f4fa3..c66e48d 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -21,7 +21,6 @@
 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;
@@ -30,6 +29,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.function.Function;
 
 class MetricJson {
   String description;
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index d97e73a..cdf67bc 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Timer1;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 
 /** Optimized version of {@link BucketedTimer} for single dimension. */
 class TimerImpl1<F1> extends BucketedTimer implements BucketedMetric {
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index be66009..685e3e1 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Description;
@@ -22,6 +21,7 @@
 import com.google.gerrit.metrics.Timer2;
 import com.google.gerrit.metrics.Timer3;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 
 /** Generalized implementation of N-dimensional timer metrics. */
 class TimerImplN extends BucketedTimer implements BucketedMetric {
diff --git a/java/com/google/gerrit/pgm/Rulec.java b/java/com/google/gerrit/pgm/Rulec.java
index aa72ae0..1592d0e 100644
--- a/java/com/google/gerrit/pgm/Rulec.java
+++ b/java/com/google/gerrit/pgm/Rulec.java
@@ -73,7 +73,7 @@
 
     LinkedHashSet<Project.NameKey> names = new LinkedHashSet<>();
     for (String name : projectNames) {
-      names.add(new Project.NameKey(name));
+      names.add(Project.nameKey(name));
     }
     if (all) {
       names.addAll(gitManager.list());
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 27e6ce9..674f9c1 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -101,7 +101,7 @@
     if (!accounts.hasAnyAccount()) {
       ui.header("Gerrit Administrator");
       if (ui.yesno(true, "Create administrator user")) {
-        Account.Id id = new Account.Id(sequencesOnInit.nextAccountId());
+        Account.Id id = Account.id(sequencesOnInit.nextAccountId());
         String username = ui.readString("admin", "username");
         String name = ui.readString("Administrator", "name");
         String httpPassword = ui.readString("secret", "HTTP password");
diff --git a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index 20e7ba2..c90124d 100644
--- a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -43,7 +43,7 @@
     super(flags, site, allProjects.get(), RefNames.REFS_CONFIG);
     this.baseConfig =
         ProjectConfig.Factory.getBaseConfig(
-            site, new AllProjectsName(allProjects.get()), new Project.NameKey(allProjects.get()));
+            site, new AllProjectsName(allProjects.get()), Project.nameKey(allProjects.get()));
   }
 
   public Config getConfig() {
@@ -71,7 +71,7 @@
 
   private GroupList readGroupList() throws IOException {
     return GroupList.parse(
-        new Project.NameKey(project),
+        Project.nameKey(project),
         readUTF8(GroupList.FILE_NAME),
         error ->
             logger.atSevere().log(
diff --git a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
index d3d22cb..71753c7 100644
--- a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -38,7 +38,7 @@
         new RepoSequence(
             repoManager,
             GitReferenceUpdated.DISABLED,
-            new Project.NameKey(allUsersName.get()),
+            Project.nameKey(allUsersName.get()),
             Sequences.NAME_ACCOUNTS,
             () -> Sequences.FIRST_ACCOUNT_ID,
             1);
diff --git a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index 738cafd..d4af255 100644
--- a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -58,7 +58,7 @@
     File path = getPath();
     if (path != null) {
       try (Repository repo = new FileRepository(path)) {
-        load(new Project.NameKey(project), repo);
+        load(Project.nameKey(project), repo);
       }
     }
     return this;
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index eed307f..98558fb 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -18,6 +18,7 @@
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.gerrit.common.Die;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
@@ -28,6 +29,7 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
+import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.inject.AbstractModule;
@@ -106,6 +108,13 @@
           });
     }
 
+    modules.add(
+        new LifecycleModule() {
+          @Override
+          protected void configure() {
+            listener().to(SystemReaderInstaller.class);
+          }
+        });
     Module configModule = new GerritServerConfigModule();
     modules.add(configModule);
     modules.add(
diff --git a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
index 546ff89..b078217 100644
--- a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
+++ b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
@@ -60,20 +60,23 @@
     return assertAbout(factory).that(actual);
   }
 
-  private SerializedClassSubject(FailureMetadata metadata, Class<?> actual) {
-    super(metadata, actual);
+  private final Class<?> clazz;
+
+  private SerializedClassSubject(FailureMetadata metadata, Class<?> clazz) {
+    super(metadata, clazz);
+    this.clazz = clazz;
   }
 
   public void isAbstract() {
     isNotNull();
-    if (!Modifier.isAbstract(actual().getModifiers())) {
+    if (!Modifier.isAbstract(clazz.getModifiers())) {
       failWithActual(simpleFact("expected class to be abstract"));
     }
   }
 
   public void isConcrete() {
     isNotNull();
-    if (Modifier.isAbstract(actual().getModifiers())) {
+    if (Modifier.isAbstract(clazz.getModifiers())) {
       failWithActual(simpleFact("expected class to be concrete"));
     }
   }
@@ -82,7 +85,7 @@
     isConcrete();
     check("fields()")
         .that(
-            FieldUtils.getAllFieldsList(actual()).stream()
+            FieldUtils.getAllFieldsList(clazz).stream()
                 .filter(f -> !Modifier.isStatic(f.getModifiers()))
                 .collect(toImmutableMap(Field::getName, Field::getGenericType)))
         .containsExactlyEntriesIn(expectedFields);
@@ -91,9 +94,9 @@
   public void hasAutoValueMethods(Map<String, Type> expectedMethods) {
     // Would be nice if we could check clazz is an @AutoValue, but the retention is not RUNTIME.
     isAbstract();
-    check("noArgumentAbstractMethodsOn(%s)", actual().getName())
+    check("noArgumentAbstractMethods()")
         .that(
-            Arrays.stream(actual().getDeclaredMethods())
+            Arrays.stream(clazz.getDeclaredMethods())
                 .filter(m -> !Modifier.isStatic(m.getModifiers()))
                 .filter(m -> Modifier.isAbstract(m.getModifiers()))
                 .filter(m -> m.getParameters().length == 0)
@@ -103,8 +106,6 @@
 
   public void extendsClass(Type superclassType) {
     isNotNull();
-    check("superclass(%s)", actual().getName())
-        .that(actual().getGenericSuperclass())
-        .isEqualTo(superclassType);
+    check("getGenericSuperclass()").that(clazz.getGenericSuperclass()).isEqualTo(superclassType);
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/reviewdb/BUILD
index 588c47a..d241140 100644
--- a/java/com/google/gerrit/reviewdb/BUILD
+++ b/java/com/google/gerrit/reviewdb/BUILD
@@ -8,9 +8,12 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gwtorm",
+        "//java/com/google/gerrit/git",
         "//lib:guava",
         "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/jgit/org.eclipse.jgit:jgit",
         "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/reviewdb/client/Account.java b/java/com/google/gerrit/reviewdb/client/Account.java
index 6a26f62..6366ce2 100644
--- a/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/java/com/google/gerrit/reviewdb/client/Account.java
@@ -18,9 +18,9 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_STARRED_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gwtorm.client.IntKey;
 import java.sql.Timestamp;
 import java.util.Optional;
 
@@ -37,8 +37,6 @@
  *   <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>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.
@@ -47,34 +45,15 @@
  */
 public final class Account {
   public static Id id(int id) {
-    return new Id(id);
+    return new AutoValue_Account_Id(id);
   }
 
   /** 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;
-
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
+  @AutoValue
+  public abstract static class Id implements Comparable<Id> {
     /** Parse an Account.Id out of a string representation. */
     public static Optional<Id> tryParse(String str) {
-      return Optional.ofNullable(Ints.tryParse(str)).map(Id::new);
+      return Optional.ofNullable(Ints.tryParse(str)).map(Account::id);
     }
 
     public static Id fromRef(String name) {
@@ -99,12 +78,12 @@
      */
     public static Id fromRefPart(String name) {
       Integer id = RefNames.parseShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
     }
 
     public static Id parseAfterShardedRefPart(String name) {
       Integer id = RefNames.parseAfterShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
     }
 
     /**
@@ -119,7 +98,23 @@
      */
     public static Id fromRefSuffix(String name) {
       Integer id = RefNames.parseRefSuffix(name);
-      return id != null ? new Account.Id(id) : null;
+      return id != null ? Account.id(id) : null;
+    }
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    @Override
+    public int compareTo(Id o) {
+      return Integer.compare(id(), o.id());
+    }
+
+    @Override
+    public String toString() {
+      return Integer.toString(get());
     }
   }
 
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
index 0db7bbd..356ea94 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroup.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.StringKey;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Objects;
@@ -34,63 +33,45 @@
   }
 
   public static NameKey nameKey(String n) {
-    return new NameKey(n);
+    return new AutoValue_AccountGroup_NameKey(n);
   }
 
   /** Group name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class NameKey implements Comparable<NameKey> {
+    abstract String name();
 
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
-    }
-
-    @Override
     public String get() {
-      return name;
+      return name();
     }
 
     @Override
-    protected void set(String newValue) {
-      name = newValue;
+    public int compareTo(NameKey o) {
+      return name().compareTo(o.name());
+    }
+
+    @Override
+    public String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
   public static UUID uuid(String n) {
-    return new UUID(n);
+    return new AutoValue_AccountGroup_UUID(n);
   }
 
   /** Globally unique identifier. */
-  public static class UUID extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class UUID implements Comparable<UUID> {
+    abstract String uuid();
 
-    protected String uuid;
-
-    protected UUID() {}
-
-    public UUID(String n) {
-      uuid = n;
-    }
-
-    @Override
     public String get() {
-      return uuid;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      uuid = newValue;
+      return uuid();
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a string representation. */
     public static UUID parse(String str) {
-      final UUID r = new UUID();
-      r.fromString(str);
-      return r;
+      return AccountGroup.uuid(KeyUtil.decode(str));
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a ref-name. */
@@ -112,7 +93,17 @@
      */
     public static UUID fromRefPart(String refPart) {
       String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
-      return uuid != null ? new AccountGroup.UUID(uuid) : null;
+      return uuid != null ? AccountGroup.uuid(uuid) : null;
+    }
+
+    @Override
+    public int compareTo(UUID o) {
+      return uuid().compareTo(o.uuid());
+    }
+
+    @Override
+    public String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
@@ -122,36 +113,26 @@
   }
 
   public static Id id(int id) {
-    return new Id(id);
+    return new AutoValue_AccountGroup_Id(id);
   }
 
   /** Synthetic key to link to within the database */
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class Id {
+    abstract int id();
 
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
     public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
+      return id();
     }
 
     /** Parse an AccountGroup.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      return AccountGroup.id(Integer.parseInt(str));
+    }
+
+    @Override
+    public String toString() {
+      return Integer.toString(get());
     }
   }
 
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
deleted file mode 100644
index 578865c..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
+++ /dev/null
@@ -1,104 +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.reviewdb.client;
-
-import com.google.gwtorm.client.CompoundKey;
-import java.util.Objects;
-
-/** Membership of an {@link AccountGroup} in an {@link AccountGroup}. */
-public final class AccountGroupById {
-  public static Key key(AccountGroup.Id groupId, AccountGroup.UUID includeUuid) {
-    return new Key(groupId, includeUuid);
-  }
-
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected AccountGroup.Id groupId;
-
-    protected AccountGroup.UUID includeUUID;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u) {
-      groupId = g;
-      includeUUID = u;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    public AccountGroup.UUID includeUuid() {
-      return getIncludeUUID();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-  }
-
-  protected Key key;
-
-  protected AccountGroupById() {}
-
-  public AccountGroupById(AccountGroupById.Key k) {
-    key = k;
-  }
-
-  public AccountGroupById.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.groupId;
-  }
-
-  public AccountGroup.UUID getIncludeUUID() {
-    return key.includeUUID;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof AccountGroupById) && Objects.equals(key, ((AccountGroupById) o).key);
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{key=" + key + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
deleted file mode 100644
index 308e1e1..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
+++ /dev/null
@@ -1,181 +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.reviewdb.client;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.CompoundKey;
-import java.sql.Timestamp;
-import java.util.Objects;
-
-/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
-public final class AccountGroupByIdAud {
-  public static Key key(AccountGroup.Id groupId, AccountGroup.UUID includeUuid, Timestamp addedOn) {
-    return new Key(groupId, includeUuid, addedOn);
-  }
-
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected AccountGroup.Id groupId;
-
-    protected AccountGroup.UUID includeUUID;
-
-    protected Timestamp addedOn;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u, Timestamp t) {
-      groupId = g;
-      includeUUID = u;
-      addedOn = t;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    public AccountGroup.UUID includeUuid() {
-      return getIncludeUUID();
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    public Timestamp addedOn() {
-      return getAddedOn();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-
-    @Override
-    public String toString() {
-      return "Key{"
-          + "groupId="
-          + groupId
-          + ", includeUUID="
-          + includeUUID
-          + ", addedOn="
-          + addedOn
-          + '}';
-    }
-  }
-
-  protected Key key;
-
-  protected Account.Id addedBy;
-
-  @Nullable protected Account.Id removedBy;
-
-  @Nullable protected Timestamp removedOn;
-
-  protected AccountGroupByIdAud() {}
-
-  public AccountGroupByIdAud(final AccountGroupById m, Account.Id adder, Timestamp when) {
-    final AccountGroup.Id group = m.getGroupId();
-    final AccountGroup.UUID include = m.getIncludeUUID();
-    key = new AccountGroupByIdAud.Key(group, include, when);
-    addedBy = adder;
-  }
-
-  public AccountGroupByIdAud(AccountGroupByIdAud.Key key, Account.Id adder) {
-    this.key = key;
-    addedBy = adder;
-  }
-
-  public AccountGroupByIdAud.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.getParentKey();
-  }
-
-  public AccountGroup.UUID getIncludeUUID() {
-    return key.getIncludeUUID();
-  }
-
-  public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Timestamp getAddedOn() {
-    return key.getAddedOn();
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof AccountGroupByIdAud)) {
-      return false;
-    }
-    AccountGroupByIdAud a = (AccountGroupByIdAud) o;
-    return Objects.equals(key, a.key)
-        && Objects.equals(addedBy, a.addedBy)
-        && Objects.equals(removedBy, a.removedBy)
-        && Objects.equals(removedOn, a.removedOn);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, addedBy, removedBy, removedOn);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{"
-        + "key="
-        + key
-        + ", addedBy="
-        + addedBy
-        + ", removedBy="
-        + removedBy
-        + ", removedOn="
-        + removedOn
-        + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAudit.java b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAudit.java
new file mode 100644
index 0000000..e421d63
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAudit.java
@@ -0,0 +1,66 @@
+// 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.reviewdb.client;
+
+import com.google.auto.value.AutoValue;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
+@AutoValue
+public abstract class AccountGroupByIdAudit {
+  public static Builder builder() {
+    return new AutoValue_AccountGroupByIdAudit.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder groupId(AccountGroup.Id groupId);
+
+    public abstract Builder includeUuid(AccountGroup.UUID includeUuid);
+
+    public abstract Builder addedBy(Account.Id addedBy);
+
+    public abstract Builder addedOn(Timestamp addedOn);
+
+    abstract Builder removedBy(Account.Id removedBy);
+
+    abstract Builder removedOn(Timestamp removedOn);
+
+    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+      return removedBy(removedBy).removedOn(removedOn);
+    }
+
+    public abstract AccountGroupByIdAudit build();
+  }
+
+  public abstract AccountGroup.Id groupId();
+
+  public abstract AccountGroup.UUID includeUuid();
+
+  public abstract Account.Id addedBy();
+
+  public abstract Timestamp addedOn();
+
+  public abstract Optional<Account.Id> removedBy();
+
+  public abstract Optional<Timestamp> removedOn();
+
+  public abstract Builder toBuilder();
+
+  public boolean isActive() {
+    return !removedOn().isPresent();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
deleted file mode 100644
index dfa7d24..0000000
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
+++ /dev/null
@@ -1,100 +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.CompoundKey;
-import java.util.Objects;
-
-/** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMember {
-  public static Key key(Account.Id accountId, AccountGroup.Id groupId) {
-    return new Key(accountId, groupId);
-  }
-
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected Account.Id accountId;
-
-    protected AccountGroup.Id groupId;
-
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
-    }
-
-    public Key(Account.Id a, AccountGroup.Id g) {
-      accountId = a;
-      groupId = g;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public Account.Id accountId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.Id getAccountGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getAccountGroupId();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-  }
-
-  protected Key key;
-
-  protected AccountGroupMember() {}
-
-  public AccountGroupMember(AccountGroupMember.Key k) {
-    key = k;
-  }
-
-  public AccountGroupMember.Key getKey() {
-    return key;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public AccountGroup.Id getAccountGroupId() {
-    return key.groupId;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof AccountGroupMember) && Objects.equals(key, ((AccountGroupMember) o).key);
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{key=" + key + "}";
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
index 5d43b4a..37b57ee 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
@@ -14,173 +14,61 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.CompoundKey;
+import com.google.auto.value.AutoValue;
 import java.sql.Timestamp;
-import java.util.Objects;
+import java.util.Optional;
 
 /** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMemberAudit {
-  public static Key key(Account.Id accountId, AccountGroup.Id groupId, Timestamp addedOn) {
-    return new Key(accountId, groupId, addedOn);
+@AutoValue
+public abstract class AccountGroupMemberAudit {
+  public static Builder builder() {
+    return new AutoValue_AccountGroupMemberAudit.Builder();
   }
 
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder groupId(AccountGroup.Id groupId);
 
-    protected Account.Id accountId;
+    public abstract Builder memberId(Account.Id accountId);
 
-    protected AccountGroup.Id groupId;
+    public abstract Builder addedBy(Account.Id addedBy);
 
-    protected Timestamp addedOn;
+    abstract Account.Id addedBy();
 
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
+    public abstract Builder addedOn(Timestamp addedOn);
+
+    abstract Timestamp addedOn();
+
+    abstract Builder removedBy(Account.Id removedBy);
+
+    abstract Builder removedOn(Timestamp removedOn);
+
+    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+      return removedBy(removedBy).removedOn(removedOn);
     }
 
-    public Key(Account.Id a, AccountGroup.Id g, Timestamp t) {
-      accountId = a;
-      groupId = g;
-      addedOn = t;
+    public Builder removedLegacy() {
+      return removed(addedBy(), addedOn());
     }
 
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public Account.Id accountId() {
-      return getParentKey();
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.Id groupId() {
-      return getGroupId();
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    public Timestamp addedOn() {
-      return getAddedOn();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-
-    @Override
-    public String toString() {
-      return "Key{"
-          + "groupId="
-          + groupId
-          + ", accountId="
-          + accountId
-          + ", addedOn="
-          + addedOn
-          + '}';
-    }
+    public abstract AccountGroupMemberAudit build();
   }
 
-  protected Key key;
+  public abstract AccountGroup.Id groupId();
 
-  protected Account.Id addedBy;
+  public abstract Account.Id memberId();
 
-  @Nullable protected Account.Id removedBy;
+  public abstract Account.Id addedBy();
 
-  @Nullable protected Timestamp removedOn;
+  public abstract Timestamp addedOn();
 
-  protected AccountGroupMemberAudit() {}
+  public abstract Optional<Account.Id> removedBy();
 
-  public AccountGroupMemberAudit(final AccountGroupMember m, Account.Id adder, Timestamp addedOn) {
-    final Account.Id who = m.getAccountId();
-    final AccountGroup.Id group = m.getAccountGroupId();
-    key = new AccountGroupMemberAudit.Key(who, group, addedOn);
-    addedBy = adder;
-  }
+  public abstract Optional<Timestamp> removedOn();
 
-  public AccountGroupMemberAudit(AccountGroupMemberAudit.Key key, Account.Id adder) {
-    this.key = key;
-    addedBy = adder;
-  }
-
-  public AccountGroupMemberAudit.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.getGroupId();
-  }
-
-  public Account.Id getMemberId() {
-    return key.getParentKey();
-  }
+  public abstract Builder toBuilder();
 
   public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public void removedLegacy() {
-    removedBy = addedBy;
-    removedOn = key.addedOn;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Timestamp getAddedOn() {
-    return key.getAddedOn();
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof AccountGroupMemberAudit)) {
-      return false;
-    }
-    AccountGroupMemberAudit a = (AccountGroupMemberAudit) o;
-    return Objects.equals(key, a.key)
-        && Objects.equals(addedBy, a.addedBy)
-        && Objects.equals(removedBy, a.removedBy)
-        && Objects.equals(removedOn, a.removedOn);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, addedBy, removedBy, removedOn);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{"
-        + "key="
-        + key
-        + ", addedBy="
-        + addedBy
-        + ", removedBy="
-        + removedBy
-        + ", removedOn="
-        + removedOn
-        + "}";
+    return !removedOn().isPresent();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/Branch.java b/java/com/google/gerrit/reviewdb/client/Branch.java
deleted file mode 100644
index 4ea49b7..0000000
--- a/java/com/google/gerrit/reviewdb/client/Branch.java
+++ /dev/null
@@ -1,115 +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.StringKey;
-
-/** Line of development within a {@link Project}. */
-public final class Branch {
-  public static NameKey nameKey(Project.NameKey projectName, String branchName) {
-    return new NameKey(projectName, RefNames.fullName(branchName));
-  }
-
-  public static NameKey nameKey(String projectName, String branchName) {
-    return nameKey(Project.nameKey(projectName), branchName);
-  }
-
-  /** Branch name key */
-  public static class NameKey extends StringKey<Project.NameKey> {
-    private static final long serialVersionUID = 1L;
-
-    protected Project.NameKey projectName;
-
-    protected String branchName;
-
-    protected NameKey() {
-      projectName = new Project.NameKey();
-    }
-
-    public NameKey(Project.NameKey proj, String branchName) {
-      projectName = proj;
-      set(branchName);
-    }
-
-    public NameKey(String proj, String branchName) {
-      this(new Project.NameKey(proj), branchName);
-    }
-
-    @Override
-    public String get() {
-      return branchName;
-    }
-
-    public String branch() {
-      return get();
-    }
-
-    @Override
-    protected void set(String newValue) {
-      branchName = RefNames.fullName(newValue);
-    }
-
-    @Override
-    public Project.NameKey getParentKey() {
-      return projectName;
-    }
-
-    public Project.NameKey project() {
-      return getParentKey();
-    }
-
-    public String getShortName() {
-      return RefNames.shortName(get());
-    }
-  }
-
-  protected NameKey name;
-  protected RevId revision;
-  protected boolean canDelete;
-
-  protected Branch() {}
-
-  public Branch(Branch.NameKey newName) {
-    name = newName;
-  }
-
-  public Branch.NameKey getNameKey() {
-    return name;
-  }
-
-  public String getName() {
-    return name.get();
-  }
-
-  public String getShortName() {
-    return name.getShortName();
-  }
-
-  public RevId getRevision() {
-    return revision;
-  }
-
-  public void setRevision(RevId id) {
-    revision = id;
-  }
-
-  public boolean getCanDelete() {
-    return canDelete;
-  }
-
-  public void setCanDelete(boolean canDelete) {
-    this.canDelete = canDelete;
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/BranchNameKey.java b/java/com/google/gerrit/reviewdb/client/BranchNameKey.java
new file mode 100644
index 0000000..bb5bfd9
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/BranchNameKey.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.auto.value.AutoValue;
+
+/** Branch name key */
+@AutoValue
+public abstract class BranchNameKey implements Comparable<BranchNameKey> {
+  public static BranchNameKey create(Project.NameKey projectName, String branchName) {
+    return new AutoValue_BranchNameKey(projectName, RefNames.fullName(branchName));
+  }
+
+  public static BranchNameKey create(String projectName, String branchName) {
+    return create(Project.nameKey(projectName), branchName);
+  }
+
+  public abstract Project.NameKey project();
+
+  public abstract String branch();
+
+  public String shortName() {
+    return RefNames.shortName(branch());
+  }
+
+  @Override
+  public int compareTo(BranchNameKey o) {
+    // TODO(dborowitz): Only compares branch name in order to match old StringKey behavior.
+    // Consider comparing project name first.
+    return branch().compareTo(o.branch());
+  }
+
+  @Override
+  public String toString() {
+    return project() + "," + KeyUtil.encode(branch());
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/Change.java b/java/com/google/gerrit/reviewdb/client/Change.java
index 79739dc..b7fd134 100644
--- a/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/java/com/google/gerrit/reviewdb/client/Change.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.StringKey;
 import java.sql.Timestamp;
 import java.util.Arrays;
 
 /**
- * A change proposed to be merged into a {@link Branch}.
+ * A change proposed to be merged into a branch.
  *
  * <p>The data graph rooted below a Change can be quite complex:
  *
@@ -94,48 +95,16 @@
  */
 public final class Change {
   public static Id id(int id) {
-    return new Id(id);
+    return new AutoValue_Change_Id(id);
   }
 
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    public int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
-    public String toRefPrefix() {
-      return refPrefixBuilder().toString();
-    }
-
-    StringBuilder refPrefixBuilder() {
-      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('/');
-    }
-
+  @AutoValue
+  public abstract static class Id {
     /** Parse a Change.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      Integer id = Ints.tryParse(str);
+      checkArgument(id != null, "invalid change ID: %s", str);
+      return Change.id(id);
     }
 
     public static Id fromRef(String ref) {
@@ -150,7 +119,7 @@
       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 Change.id(Integer.parseInt(ref.substring(cs, ce)));
       }
       return null;
     }
@@ -173,7 +142,7 @@
       }
       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 Change.id(Integer.parseInt(ref.substring(cs, ce)));
       }
       return null;
     }
@@ -195,14 +164,14 @@
       int endChangeId = nextNonDigit(ref, startChangeId);
       String id = ref.substring(startChangeId, endChangeId);
       if (id != null && !id.isEmpty()) {
-        return new Change.Id(Integer.parseInt(id));
+        return Change.id(Integer.parseInt(id));
       }
       return null;
     }
 
     public static Id fromRefPart(String ref) {
       Integer id = RefNames.parseShardedRefPart(ref);
-      return id != null ? new Change.Id(id) : null;
+      return id != null ? Change.id(id) : null;
     }
 
     static int startIndex(String ref) {
@@ -253,35 +222,52 @@
       }
       return i;
     }
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    public String toRefPrefix() {
+      return refPrefixBuilder().toString();
+    }
+
+    StringBuilder refPrefixBuilder() {
+      StringBuilder r = new StringBuilder(32).append(REFS_CHANGES);
+      int m = get() % 100;
+      if (m < 10) {
+        r.append('0');
+      }
+      return r.append(m).append('/').append(get()).append('/');
+    }
+
+    @Override
+    public String toString() {
+      return Integer.toString(get());
+    }
   }
 
   public static Key key(String key) {
-    return new Key(key);
+    return new AutoValue_Change_Key(key);
   }
 
   /**
    * Globally unique identification of this change. This generally takes the form of a string
    * "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
    */
-  public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    protected String id;
-
-    protected Key() {}
-
-    public Key(String id) {
-      this.id = id;
+  @AutoValue
+  public abstract static class Key {
+    // TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key?
+    // Ideally the standard key() factory method would enforce the format and throw IAE.
+    public static Key parse(String str) {
+      return Change.key(KeyUtil.decode(str));
     }
 
-    @Override
+    abstract String key();
+
     public String get() {
-      return id;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      id = newValue;
+      return key();
     }
 
     /** Construct a key that is after all keys prefixed by this key. */
@@ -289,7 +275,7 @@
       final StringBuilder revEnd = new StringBuilder(get().length() + 1);
       revEnd.append(get());
       revEnd.append('\u9fa5');
-      return new Key(revEnd.toString());
+      return Change.key(revEnd.toString());
     }
 
     /** Obtain a shorter version of this key string, using a leading prefix. */
@@ -298,11 +284,9 @@
       return s.substring(0, Math.min(s.length(), 9));
     }
 
-    /** Parse a Change.Key out of a string representation. */
-    public static Key parse(String str) {
-      final Key r = new Key();
-      r.fromString(str);
-      return r;
+    @Override
+    public String toString() {
+      return get();
     }
   }
 
@@ -456,7 +440,7 @@
   protected Account.Id owner;
 
   /** The branch (and project) this change merges into. */
-  protected Branch.NameKey dest;
+  protected BranchNameKey dest;
 
   // DELETED: id = 9 (open)
 
@@ -512,7 +496,7 @@
       Change.Key newKey,
       Change.Id newId,
       Account.Id ownedBy,
-      Branch.NameKey forBranch,
+      BranchNameKey forBranch,
       Timestamp ts) {
     changeKey = newKey;
     changeId = newId;
@@ -599,16 +583,16 @@
     this.owner = owner;
   }
 
-  public Branch.NameKey getDest() {
+  public BranchNameKey getDest() {
     return dest;
   }
 
-  public void setDest(Branch.NameKey dest) {
+  public void setDest(BranchNameKey dest) {
     this.dest = dest;
   }
 
   public Project.NameKey getProject() {
-    return dest.getParentKey();
+    return dest.project();
   }
 
   public String getSubject() {
@@ -626,7 +610,7 @@
   /** Get the id of the most current {@link PatchSet} in this change. */
   public PatchSet.Id currentPatchSetId() {
     if (currentPatchSetId > 0) {
-      return new PatchSet.Id(changeId, currentPatchSetId);
+      return PatchSet.id(changeId, currentPatchSetId);
     }
     return null;
   }
@@ -649,7 +633,7 @@
   }
 
   public void setCurrentPatchSet(PatchSet.Id psId, String subject, String originalSubject) {
-    if (!psId.getParentKey().equals(changeId)) {
+    if (!psId.changeId().equals(changeId)) {
       throw new IllegalArgumentException("patch set ID " + psId + " is not for change " + changeId);
     }
     currentPatchSetId = psId.get();
diff --git a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index de318bd..cc9c35e 100644
--- a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -14,55 +14,22 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-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 {
   public static Key key(Change.Id changeId, String uuid) {
-    return new Key(changeId, uuid);
+    return new AutoValue_ChangeMessage_Key(changeId, uuid);
   }
 
-  public static class Key extends StringKey<Change.Id> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class Key {
+    public abstract Change.Id changeId();
 
-    protected Change.Id changeId;
-
-    protected String uuid;
-
-    protected Key() {
-      changeId = new Change.Id();
-    }
-
-    public Key(Change.Id change, String uuid) {
-      this.changeId = change;
-      this.uuid = uuid;
-    }
-
-    @Override
-    public Change.Id getParentKey() {
-      return changeId;
-    }
-
-    public Change.Id changeId() {
-      return getParentKey();
-    }
-
-    @Override
-    public String get() {
-      return uuid;
-    }
-
-    public String uuid() {
-      return get();
-    }
-
-    @Override
-    public void set(String newValue) {
-      uuid = newValue;
-    }
+    public abstract String uuid();
   }
 
   protected Key key;
diff --git a/java/com/google/gerrit/reviewdb/client/Comment.java b/java/com/google/gerrit/reviewdb/client/Comment.java
index e03d0fa..d1f97fb 100644
--- a/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -14,9 +14,14 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 import java.util.Comparator;
 import java.util.Objects;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * This class represents inline comments in NoteDb. This means it determines the JSON format for
@@ -65,17 +70,10 @@
 
     @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('}')
+      return MoreObjects.toStringHelper(this)
+          .add("uuid", uuid)
+          .add("filename", filename)
+          .add("patchSetId", patchSetId)
           .toString();
     }
 
@@ -104,7 +102,7 @@
     }
 
     public Account.Id getId() {
-      return new Account.Id(id);
+      return Account.id(id);
     }
 
     @Override
@@ -122,12 +120,7 @@
 
     @Override
     public String toString() {
-      return new StringBuilder()
-          .append("Comment.Identity{")
-          .append("id=")
-          .append(id)
-          .append('}')
-          .toString();
+      return MoreObjects.toStringHelper(this).add("id", id).toString();
     }
   }
 
@@ -177,20 +170,11 @@
 
     @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('}')
+      return MoreObjects.toStringHelper(this)
+          .add("startLine", startLine)
+          .add("startChar", startChar)
+          .add("endLine", endLine)
+          .add("endChar", endChar)
           .toString();
     }
 
@@ -211,8 +195,12 @@
   public Range range;
   public String tag;
 
-  // Hex commit SHA1 of the commit of the patchset to which this comment applies.
-  public String revId;
+  // Hex commit SHA1 of the commit of the patchset to which this comment applies. Other classes call
+  // this "commitId", but this class uses the old ReviewDb term "revId", and this field name is
+  // serialized into JSON in NoteDb, so it can't easily be changed. Callers do not access this field
+  // directly, and instead use the public getter/setter that wraps an ObjectId.
+  private String revId;
+
   public String serverId;
   public boolean unresolved;
 
@@ -269,8 +257,13 @@
     this.range = range != null ? range.asCommentRange() : null;
   }
 
-  public void setRevId(RevId revId) {
-    this.revId = revId != null ? revId.get() : null;
+  @Nullable
+  public ObjectId getCommitId() {
+    return revId != null ? ObjectId.fromString(revId) : null;
+  }
+
+  public void setCommitId(@Nullable AnyObjectId commitId) {
+    this.revId = commitId != null ? commitId.name() : null;
   }
 
   public void setRealAuthor(Account.Id id) {
@@ -322,44 +315,22 @@
 
   @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();
+    return toStringHelper().toString();
+  }
+
+  protected ToStringHelper toStringHelper() {
+    return MoreObjects.toStringHelper(this)
+        .add("key", key)
+        .add("lineNbr", lineNbr)
+        .add("author", author.getId())
+        .add("realAuthor", realAuthor != null ? realAuthor.getId() : "")
+        .add("writtenOn", writtenOn)
+        .add("side", side)
+        .add("message", Objects.toString(message, ""))
+        .add("parentUuid", Objects.toString(parentUuid, ""))
+        .add("range", Objects.toString(range, ""))
+        .add("revId", Objects.toString(revId, ""))
+        .add("tag", Objects.toString(tag, ""))
+        .add("unresolved", unresolved);
   }
 }
diff --git a/java/com/google/gwtorm/client/StandardKeyEncoder.java b/java/com/google/gerrit/reviewdb/client/KeyUtil.java
similarity index 91%
rename from java/com/google/gwtorm/client/StandardKeyEncoder.java
rename to java/com/google/gerrit/reviewdb/client/KeyUtil.java
index d6d503a..c6539a3 100644
--- a/java/com/google/gwtorm/client/StandardKeyEncoder.java
+++ b/java/com/google/gerrit/reviewdb/client/KeyUtil.java
@@ -1,4 +1,4 @@
-// Copyright 2008 Google Inc.
+// Copyright (C) 2019 The Android Open 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,13 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gwtorm.client;
+package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.KeyUtil.Encoder;
 import java.io.UnsupportedEncodingException;
 import java.util.Arrays;
 
-public class StandardKeyEncoder extends Encoder {
+public class KeyUtil {
   private static final char[] hexc = {
     '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
   };
@@ -49,8 +48,7 @@
     for (char i = 'a'; i <= 'f'; i++) hexb[i] = (byte) ((i - 'a') + 10);
   }
 
-  @Override
-  public String encode(final String e) {
+  public static String encode(final String e) {
     final byte[] b;
     try {
       b = e.getBytes("UTF-8");
@@ -73,8 +71,7 @@
     return r.toString();
   }
 
-  @Override
-  public String decode(final String e) {
+  public static String decode(final String e) {
     if (e.indexOf('%') < 0) {
       return e.replace('+', ' ');
     }
diff --git a/java/com/google/gerrit/reviewdb/client/LabelId.java b/java/com/google/gerrit/reviewdb/client/LabelId.java
index abf131b..31056c4 100644
--- a/java/com/google/gerrit/reviewdb/client/LabelId.java
+++ b/java/com/google/gerrit/reviewdb/client/LabelId.java
@@ -14,36 +14,23 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.StringKey;
+import com.google.auto.value.AutoValue;
 
-public class LabelId extends StringKey<com.google.gwtorm.client.Key<?>> {
-  private static final long serialVersionUID = 1L;
-
+@AutoValue
+public abstract class LabelId {
   static final String LEGACY_SUBMIT_NAME = "SUBM";
 
   public static LabelId create(String n) {
-    return new LabelId(n);
+    return new AutoValue_LabelId(n);
   }
 
   public static LabelId legacySubmit() {
-    return new LabelId(LEGACY_SUBMIT_NAME);
+    return create(LEGACY_SUBMIT_NAME);
   }
 
-  public String id;
+  abstract String id();
 
-  public LabelId() {}
-
-  public LabelId(String n) {
-    id = n;
-  }
-
-  @Override
   public String get() {
-    return id;
-  }
-
-  @Override
-  protected void set(String newValue) {
-    id = newValue;
+    return id();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/Patch.java b/java/com/google/gerrit/reviewdb/client/Patch.java
index d192df0..0f7e4cf 100644
--- a/java/com/google/gerrit/reviewdb/client/Patch.java
+++ b/java/com/google/gerrit/reviewdb/client/Patch.java
@@ -14,7 +14,12 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.StringKey;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.primitives.Ints;
+import java.util.List;
 
 /** A single modified file in a {@link PatchSet}. */
 public final class Patch {
@@ -36,58 +41,29 @@
   }
 
   public static Key key(PatchSet.Id patchSetId, String fileName) {
-    return new Key(patchSetId, fileName);
+    return new AutoValue_Patch_Key(patchSetId, fileName);
   }
 
-  public static class Key extends StringKey<PatchSet.Id> {
-    private static final long serialVersionUID = 1L;
-
-    protected PatchSet.Id patchSetId;
-
-    protected String fileName;
-
-    protected Key() {
-      patchSetId = new PatchSet.Id();
-    }
-
-    public Key(PatchSet.Id ps, String name) {
-      this.patchSetId = ps;
-      this.fileName = name;
-    }
-
-    @Override
-    public PatchSet.Id getParentKey() {
-      return patchSetId;
-    }
-
-    public PatchSet.Id patchSetId() {
-      return getParentKey();
-    }
-
-    @Override
-    public String get() {
-      return fileName;
-    }
-
-    public String fileName() {
-      return get();
-    }
-
-    @Override
-    protected void set(String newValue) {
-      fileName = newValue;
-    }
-
-    /** Parse a Patch.Id out of a string representation. */
+  @AutoValue
+  public abstract static class Key {
+    /** Parse a Patch.Key out of a string representation. */
     public static Key parse(String str) {
-      final Key r = new Key();
-      r.fromString(str);
-      return r;
+      List<String> parts = Splitter.on(',').limit(3).splitToList(str);
+      checkKeyFormat(parts.size() == 3, str);
+      Integer changeId = Ints.tryParse(parts.get(0));
+      checkKeyFormat(changeId != null, str);
+      Integer patchSetNum = Ints.tryParse(parts.get(1));
+      checkKeyFormat(patchSetNum != null, str);
+      return key(PatchSet.id(Change.id(changeId), patchSetNum), parts.get(2));
     }
 
-    public String getFileName() {
-      return get();
+    private static void checkKeyFormat(boolean test, String input) {
+      checkArgument(test, "invalid patch key: %s", input);
     }
+
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract String fileName();
   }
 
   /** Type of modification made to the file path. */
@@ -271,7 +247,7 @@
   }
 
   public String getFileName() {
-    return key.fileName;
+    return key.fileName();
   }
 
   public String getSourceFileName() {
diff --git a/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
index ce218c0..5dbe68f 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
@@ -16,9 +16,10 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gwtorm.client.StringKey;
 import java.sql.Timestamp;
 import java.util.Objects;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * A comment left by a user on a specific line of a {@link Patch}.
@@ -28,48 +29,6 @@
  * @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);
-    }
-
-    protected Patch.Key patchKey;
-
-    protected String uuid;
-
-    protected Key() {
-      patchKey = new Patch.Key();
-    }
-
-    public Key(Patch.Key p, String uuid) {
-      this.patchKey = p;
-      this.uuid = uuid;
-    }
-
-    @Override
-    public Patch.Key getParentKey() {
-      return patchKey;
-    }
-
-    @Override
-    public String get() {
-      return uuid;
-    }
-
-    @Override
-    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';
   public static final char STATUS_PUBLISHED = 'P';
 
@@ -100,12 +59,10 @@
 
   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);
-
+    Patch.Key patchKey = Patch.key(PatchSet.id(changeId, c.key.patchSetId), c.key.filename);
     PatchLineComment plc =
-        new PatchLineComment(key, c.lineNbr, c.author.getId(), c.parentUuid, c.writtenOn);
+        new PatchLineComment(
+            patchKey, c.key.uuid, c.lineNbr, c.author.getId(), c.parentUuid, c.writtenOn);
     plc.setSide(c.side);
     plc.setMessage(c.message);
     if (c.range != null) {
@@ -113,14 +70,16 @@
       plc.setRange(new CommentRange(r.startLine, r.startChar, r.endLine, r.endChar));
     }
     plc.setTag(c.tag);
-    plc.setRevId(new RevId(c.revId));
+    plc.setCommitId(c.getCommitId());
     plc.setStatus(status);
     plc.setRealAuthor(c.getRealAuthor().getId());
     plc.setUnresolved(c.unresolved);
     return plc;
   }
 
-  protected Key key;
+  protected Patch.Key patchKey;
+
+  protected String uuid;
 
   /** Line number this comment applies to; it should display after the line. */
   protected int lineNbr;
@@ -153,14 +112,15 @@
   /** True if this comment requires further action. */
   protected boolean unresolved;
 
-  /** The RevId for the commit to which this comment is referring. */
-  protected RevId revId;
+  /** The ID of the commit to which this comment is referring. */
+  protected ObjectId commitId;
 
   protected PatchLineComment() {}
 
   public PatchLineComment(
-      PatchLineComment.Key id, int line, Account.Id a, String parentUuid, Timestamp when) {
-    key = id;
+      Patch.Key patchKey, String uuid, int line, Account.Id a, String parentUuid, Timestamp when) {
+    this.patchKey = patchKey;
+    this.uuid = uuid;
     lineNbr = line;
     author = a;
     setParentUuid(parentUuid);
@@ -169,7 +129,8 @@
   }
 
   public PatchLineComment(PatchLineComment o) {
-    key = o.key;
+    patchKey = o.patchKey;
+    uuid = o.uuid;
     lineNbr = o.lineNbr;
     author = o.author;
     realAuthor = o.realAuthor;
@@ -178,7 +139,7 @@
     side = o.side;
     message = o.message;
     parentUuid = o.parentUuid;
-    revId = o.revId;
+    commitId = o.commitId;
     if (o.range != null) {
       range =
           new CommentRange(
@@ -189,12 +150,8 @@
     }
   }
 
-  public PatchLineComment.Key getKey() {
-    return key;
-  }
-
   public PatchSet.Id getPatchSetId() {
-    return key.getParentKey().getParentKey();
+    return patchKey.patchSetId();
   }
 
   public int getLine() {
@@ -277,12 +234,12 @@
     return range;
   }
 
-  public void setRevId(RevId rev) {
-    revId = rev;
+  public void setCommitId(AnyObjectId commitId) {
+    this.commitId = commitId.copy();
   }
 
-  public RevId getRevId() {
-    return revId;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public void setTag(String tag) {
@@ -303,8 +260,15 @@
 
   public Comment asComment(String serverId) {
     Comment c =
-        new Comment(key.asCommentKey(), author, writtenOn, side, message, serverId, unresolved);
-    c.setRevId(revId);
+        new Comment(
+            new Comment.Key(uuid, patchKey.fileName(), patchKey.patchSetId().get()),
+            author,
+            writtenOn,
+            side,
+            message,
+            serverId,
+            unresolved);
+    c.setCommitId(commitId);
     c.setRange(range);
     c.lineNbr = lineNbr;
     c.parentUuid = parentUuid;
@@ -317,7 +281,8 @@
   public boolean equals(Object o) {
     if (o instanceof PatchLineComment) {
       PatchLineComment c = (PatchLineComment) o;
-      return Objects.equals(key, c.getKey())
+      return Objects.equals(patchKey, c.patchKey)
+          && Objects.equals(uuid, c.uuid)
           && Objects.equals(lineNbr, c.getLine())
           && Objects.equals(author, c.getAuthor())
           && Objects.equals(writtenOn, c.getWrittenOn())
@@ -326,7 +291,7 @@
           && Objects.equals(message, c.getMessage())
           && Objects.equals(parentUuid, c.getParentUuid())
           && Objects.equals(range, c.getRange())
-          && Objects.equals(revId, c.getRevId())
+          && Objects.equals(commitId, c.getCommitId())
           && Objects.equals(tag, c.getTag())
           && Objects.equals(unresolved, c.getUnresolved());
     }
@@ -335,14 +300,15 @@
 
   @Override
   public int hashCode() {
-    return key.hashCode();
+    return Objects.hash(patchKey, uuid);
   }
 
   @Override
   public String toString() {
     StringBuilder builder = new StringBuilder();
     builder.append("PatchLineComment{");
-    builder.append("key=").append(key).append(',');
+    builder.append("patchKey=").append(patchKey).append(',');
+    builder.append("uuid=").append(uuid).append(',');
     builder.append("lineNbr=").append(lineNbr).append(',');
     builder.append("author=").append(author.get()).append(',');
     builder.append("realAuthor=").append(realAuthor != null ? realAuthor.get() : "").append(',');
@@ -352,7 +318,7 @@
     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("revId=").append(commitId != null ? commitId.name() : "").append(',');
     builder.append("tag=").append(Objects.toString(tag, "")).append(',');
     builder.append("unresolved=").append(unresolved);
     builder.append('}');
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSet.java b/java/com/google/gerrit/reviewdb/client/PatchSet.java
index 684f092..7c0af0f 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -14,16 +14,23 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.IntKey;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.common.primitives.Ints;
 import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** A single revision of a {@link Change}. */
-public final class PatchSet {
+@AutoValue
+public abstract class PatchSet {
   /** Is the reference name a change reference? */
   public static boolean isChangeRef(String name) {
     return Id.fromRef(name) != null;
@@ -40,91 +47,39 @@
   }
 
   public static String joinGroups(List<String> groups) {
-    if (groups == null) {
-      throw new IllegalArgumentException("groups may not be null");
+    requireNonNull(groups);
+    for (String group : groups) {
+      checkArgument(!group.contains(","), "group may not contain ',': %s", group);
     }
-    StringBuilder sb = new StringBuilder();
-    boolean first = true;
-    for (String g : groups) {
-      if (!first) {
-        sb.append(',');
-      } else {
-        first = false;
-      }
-      sb.append(g);
-    }
-    return sb.toString();
+    return String.join(",", groups);
   }
 
-  public static List<String> splitGroups(String joinedGroups) {
-    if (joinedGroups == null) {
-      throw new IllegalArgumentException("groups may not be null");
-    }
-    List<String> groups = new ArrayList<>();
-    int i = 0;
-    while (true) {
-      int idx = joinedGroups.indexOf(',', i);
-      if (idx < 0) {
-        groups.add(joinedGroups.substring(i));
-        break;
-      }
-      groups.add(joinedGroups.substring(i, idx));
-      i = idx + 1;
-    }
-    return groups;
+  public static ImmutableList<String> splitGroups(String joinedGroups) {
+    return Streams.stream(Splitter.on(',').split(joinedGroups)).collect(toImmutableList());
   }
 
   public static Id id(Change.Id changeId, int id) {
-    return new Id(changeId, id);
+    return new AutoValue_PatchSet_Id(changeId, id);
   }
 
-  public static class Id extends IntKey<Change.Id> {
-    private static final long serialVersionUID = 1L;
-
-    public Change.Id changeId;
-
-    public int patchSetId;
-
-    public Id() {
-      changeId = new Change.Id();
-    }
-
-    public Id(Change.Id change, int id) {
-      this.changeId = change;
-      this.patchSetId = id;
-    }
-
-    @Override
-    public Change.Id getParentKey() {
-      return changeId;
-    }
-
-    public Change.Id changeId() {
-      return getParentKey();
-    }
-
-    @Override
-    public int get() {
-      return patchSetId;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      patchSetId = newValue;
-    }
-
-    public String toRefName() {
-      return changeId.refPrefixBuilder().append(patchSetId).toString();
-    }
-
+  @AutoValue
+  public abstract static class Id {
     /** Parse a PatchSet.Id out of a string representation. */
     public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
+      List<String> parts = Splitter.on(',').splitToList(str);
+      checkIdFormat(parts.size() == 2, str);
+      Integer changeId = Ints.tryParse(parts.get(0));
+      checkIdFormat(changeId != null, str);
+      Integer id = Ints.tryParse(parts.get(1));
+      checkIdFormat(id != null, str);
+      return PatchSet.id(Change.id(changeId), id);
     }
 
-    /** Parse a PatchSet.Id from a {@link PatchSet#getRefName()} result. */
+    private static void checkIdFormat(boolean test, String input) {
+      checkArgument(test, "invalid patch set ID: %s", input);
+    }
+
+    /** Parse a PatchSet.Id from a {@link #refName()} result. */
     public static Id fromRef(String ref) {
       int cs = Change.Id.startIndex(ref);
       if (cs < 0) {
@@ -136,7 +91,7 @@
         return null;
       }
       int changeId = Integer.parseInt(ref.substring(cs, ce));
-      return new PatchSet.Id(new Change.Id(changeId), patchSetId);
+      return PatchSet.id(Change.id(changeId), patchSetId);
     }
 
     static int fromRef(String ref, int changeIdEnd) {
@@ -153,23 +108,96 @@
       return Integer.parseInt(ref.substring(ps));
     }
 
-    public String getId() {
-      return toId(patchSetId);
-    }
-
     public static String toId(int number) {
       return number == 0 ? "edit" : String.valueOf(number);
     }
+
+    public String getId() {
+      return toId(id());
+    }
+
+    public abstract Change.Id changeId();
+
+    abstract int id();
+
+    public int get() {
+      return id();
+    }
+
+    public String toRefName() {
+      return changeId().refPrefixBuilder().append(id()).toString();
+    }
+
+    @Override
+    public String toString() {
+      return changeId().toString() + ',' + id();
+    }
   }
 
-  protected Id id;
+  public static Builder builder() {
+    return new AutoValue_PatchSet.Builder().groups(ImmutableList.of());
+  }
 
-  @Nullable protected RevId revision;
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder id(Id id);
 
-  protected Account.Id uploader;
+    public abstract Id id();
 
-  /** When this patch set was first introduced onto the change. */
-  protected Timestamp createdOn;
+    public abstract Builder commitId(ObjectId commitId);
+
+    public abstract Optional<ObjectId> commitId();
+
+    public abstract Builder uploader(Account.Id uploader);
+
+    public abstract Builder createdOn(Timestamp createdOn);
+
+    public abstract Builder groups(Iterable<String> groups);
+
+    public abstract ImmutableList<String> groups();
+
+    public abstract Builder pushCertificate(Optional<String> pushCertificate);
+
+    public abstract Builder pushCertificate(String pushCertificate);
+
+    public abstract Builder description(Optional<String> description);
+
+    public abstract Builder description(String description);
+
+    public abstract Optional<String> description();
+
+    public abstract PatchSet build();
+  }
+
+  /** ID of the patch set. */
+  public abstract Id id();
+
+  /**
+   * Commit ID of the patch set, also known as the revision.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include a {@code commitId}, then this method will
+   * return {@link ObjectId#zeroId()}.
+   */
+  public abstract ObjectId commitId();
+
+  /**
+   * Account that uploaded the patch set.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include an {@code uploader}, then this method will
+   * return an account ID of 0.
+   */
+  public abstract Account.Id uploader();
+
+  /**
+   * When this patch set was first introduced onto the change.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data erroneously did not include a {@code createdOn}, then this method will
+   * return a timestamp of 0.
+   */
+  public abstract Timestamp createdOn();
 
   /**
    * Opaque group identifier, usually assigned during creation.
@@ -180,125 +208,26 @@
    * <p>Changes on the same branch having patch sets with intersecting groups are considered
    * related, as in the "Related Changes" tab.
    */
-  @Nullable protected String groups;
-
-  // DELETED id = 7 (pushCertficate)
+  public abstract ImmutableList<String> groups();
 
   /** Certificate sent with a push that created this patch set. */
-  @Nullable protected String pushCertificate;
+  public abstract Optional<String> pushCertificate();
 
   /**
    * 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.
+   * <p>When this field is an empty {@code Optional}, the description was never set on the patch
+   * set. When this field is present but an empty string, the description was set and later cleared.
    */
-  @Nullable protected String description;
+  public abstract Optional<String> description();
 
-  protected PatchSet() {}
-
-  public PatchSet(PatchSet.Id k) {
-    id = k;
+  /** Patch set number. */
+  public int number() {
+    return id().get();
   }
 
-  public PatchSet(PatchSet src) {
-    this.id = src.id;
-    this.revision = src.revision;
-    this.uploader = src.uploader;
-    this.createdOn = src.createdOn;
-    this.groups = src.groups;
-    this.pushCertificate = src.pushCertificate;
-    this.description = src.description;
-  }
-
-  public PatchSet.Id getId() {
-    return id;
-  }
-
-  public int getPatchSetId() {
-    return id.get();
-  }
-
-  public RevId getRevision() {
-    return revision;
-  }
-
-  public void setRevision(RevId i) {
-    revision = i;
-  }
-
-  public Account.Id getUploader() {
-    return uploader;
-  }
-
-  public void setUploader(Account.Id who) {
-    uploader = who;
-  }
-
-  public Timestamp getCreatedOn() {
-    return createdOn;
-  }
-
-  public void setCreatedOn(Timestamp ts) {
-    createdOn = ts;
-  }
-
-  public List<String> getGroups() {
-    if (groups == null) {
-      return Collections.emptyList();
-    }
-    return splitGroups(groups);
-  }
-
-  public void setGroups(List<String> groups) {
-    if (groups == null) {
-      groups = Collections.emptyList();
-    }
-    this.groups = joinGroups(groups);
-  }
-
-  public String getRefName() {
-    return id.toRefName();
-  }
-
-  public String getPushCertificate() {
-    return pushCertificate;
-  }
-
-  public void setPushCertificate(String cert) {
-    pushCertificate = cert;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String description) {
-    this.description = description;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof PatchSet)) {
-      return false;
-    }
-    PatchSet p = (PatchSet) o;
-    return Objects.equals(id, p.id)
-        && Objects.equals(revision, p.revision)
-        && Objects.equals(uploader, p.uploader)
-        && Objects.equals(createdOn, p.createdOn)
-        && Objects.equals(groups, p.groups)
-        && Objects.equals(pushCertificate, p.pushCertificate)
-        && Objects.equals(description, p.description);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(id, revision, uploader, createdOn, groups, pushCertificate, description);
-  }
-
-  @Override
-  public String toString() {
-    return "[PatchSet " + getId().toString() + "]";
+  /** Name of the corresponding patch set ref. */
+  public String refName() {
+    return id().toRefName();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index e1c4ea9..c5c8166 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -14,71 +14,75 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gerrit.common.Nullable;
-import com.google.gwtorm.client.CompoundKey;
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Shorts;
 import java.sql.Timestamp;
 import java.util.Date;
-import java.util.Objects;
+import java.util.Optional;
 
 /** An approval (or negative approval) on a patch set. */
-public final class PatchSetApproval {
+@AutoValue
+public abstract class PatchSetApproval {
   public static Key key(PatchSet.Id patchSetId, Account.Id accountId, LabelId labelId) {
-    return new Key(patchSetId, accountId, labelId);
+    return new AutoValue_PatchSetApproval_Key(patchSetId, accountId, labelId);
   }
 
-  public static class Key extends CompoundKey<PatchSet.Id> {
-    private static final long serialVersionUID = 1L;
+  @AutoValue
+  public abstract static class Key {
+    public abstract PatchSet.Id patchSetId();
 
-    protected PatchSet.Id patchSetId;
+    public abstract Account.Id accountId();
 
-    protected Account.Id accountId;
+    public abstract LabelId labelId();
 
-    protected LabelId categoryId;
-
-    protected Key() {
-      patchSetId = new PatchSet.Id();
-      accountId = new Account.Id();
-      categoryId = new LabelId();
-    }
-
-    public Key(PatchSet.Id ps, Account.Id a, LabelId c) {
-      this.patchSetId = ps;
-      this.accountId = a;
-      this.categoryId = c;
-    }
-
-    @Override
-    public PatchSet.Id getParentKey() {
-      return patchSetId;
-    }
-
-    public PatchSet.Id patchSetId() {
-      return getParentKey();
-    }
-
-    public Account.Id getAccountId() {
-      return accountId;
-    }
-
-    public Account.Id accountId() {
-      return getAccountId();
-    }
-
-    public LabelId getLabelId() {
-      return categoryId;
-    }
-
-    public LabelId labelId() {
-      return getLabelId();
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {accountId, categoryId};
+    public boolean isLegacySubmit() {
+      return LabelId.LEGACY_SUBMIT_NAME.equals(labelId().get());
     }
   }
 
-  protected Key key;
+  public static Builder builder() {
+    return new AutoValue_PatchSetApproval.Builder().postSubmit(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder key(Key key);
+
+    public abstract Key key();
+
+    public abstract Builder value(short value);
+
+    public Builder value(int value) {
+      return value(Shorts.checkedCast(value));
+    }
+
+    public abstract Builder granted(Timestamp granted);
+
+    public Builder granted(Date granted) {
+      return granted(new Timestamp(granted.getTime()));
+    }
+
+    public abstract Builder tag(String tag);
+
+    public abstract Builder tag(Optional<String> tag);
+
+    public abstract Builder realAccountId(Account.Id realAccountId);
+
+    abstract Optional<Account.Id> realAccountId();
+
+    public abstract Builder postSubmit(boolean isPostSubmit);
+
+    abstract PatchSetApproval autoBuild();
+
+    public PatchSetApproval build() {
+      if (!realAccountId().isPresent()) {
+        realAccountId(key().accountId());
+      }
+      return autoBuild();
+    }
+  }
+
+  public abstract Key key();
 
   /**
    * Value assigned by the user.
@@ -96,143 +100,40 @@
    * and in the negative and positive direction a magnitude can be assumed.The further from 0 the
    * more assertive the approval.
    */
-  protected short value;
+  public abstract short value();
 
-  protected Timestamp granted;
+  public abstract Timestamp granted();
 
-  @Nullable protected String tag;
+  public abstract Optional<String> tag();
 
   /** Real user that made this approval on behalf of the user recorded in {@link Key#accountId}. */
-  @Nullable protected Account.Id realAccountId;
+  public abstract Account.Id realAccountId();
 
-  protected boolean postSubmit;
+  public abstract boolean postSubmit();
 
-  // DELETED: id = 4 (changeOpen)
-  // DELETED: id = 5 (changeSortKey)
+  public abstract Builder toBuilder();
 
-  protected PatchSetApproval() {}
-
-  public PatchSetApproval(PatchSetApproval.Key k, short v, Date ts) {
-    key = k;
-    setValue(v);
-    setGranted(ts);
+  public PatchSetApproval copyWithPatchSet(PatchSet.Id psId) {
+    return toBuilder().key(key(psId, key().accountId(), key().labelId())).build();
   }
 
-  public PatchSetApproval(PatchSet.Id psId, PatchSetApproval src) {
-    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 PatchSet.Id patchSetId() {
+    return key().patchSetId();
   }
 
-  public PatchSetApproval(PatchSetApproval src) {
-    this(src.getPatchSetId(), src);
+  public Account.Id accountId() {
+    return key().accountId();
   }
 
-  public PatchSetApproval.Key getKey() {
-    return key;
+  public LabelId labelId() {
+    return key().labelId();
   }
 
-  public PatchSet.Id getPatchSetId() {
-    return key.patchSetId;
-  }
-
-  public Account.Id getAccountId() {
-    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;
-  }
-
-  public short getValue() {
-    return value;
-  }
-
-  public void setValue(short v) {
-    value = v;
-  }
-
-  public Timestamp getGranted() {
-    return granted;
-  }
-
-  public void setGranted(Date when) {
-    if (when instanceof Timestamp) {
-      granted = (Timestamp) when;
-    } else {
-      granted = new Timestamp(when.getTime());
-    }
-  }
-
-  public void setTag(String t) {
-    tag = t;
-  }
-
-  public String getLabel() {
-    return getLabelId().get();
+  public String label() {
+    return labelId().get();
   }
 
   public boolean isLegacySubmit() {
-    return LabelId.LEGACY_SUBMIT_NAME.equals(getLabel());
-  }
-
-  public String getTag() {
-    return tag;
-  }
-
-  public void setPostSubmit(boolean postSubmit) {
-    this.postSubmit = postSubmit;
-  }
-
-  public boolean isPostSubmit() {
-    return postSubmit;
-  }
-
-  @Override
-  public String 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
-  public boolean equals(Object o) {
-    if (o instanceof PatchSetApproval) {
-      PatchSetApproval p = (PatchSetApproval) o;
-      return Objects.equals(key, p.key)
-          && Objects.equals(value, p.value)
-          && Objects.equals(granted, p.granted)
-          && Objects.equals(tag, p.tag)
-          && Objects.equals(realAccountId, p.realAccountId)
-          && postSubmit == p.postSubmit;
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, value, granted, tag, realAccountId, postSubmit);
+    return key().isLegacySubmit();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java b/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
index f949013..21f6756 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
@@ -14,17 +14,21 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import static java.util.Objects.requireNonNull;
+
 import java.util.List;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Additional data about a {@link PatchSet} not normally loaded. */
 public final class PatchSetInfo {
   public static class ParentInfo {
-    public RevId id;
+    public ObjectId commitId;
     public String shortMessage;
 
-    public ParentInfo(RevId id, String shortMessage) {
-      this.id = id;
-      this.shortMessage = shortMessage;
+    public ParentInfo(AnyObjectId commitId, String shortMessage) {
+      this.commitId = commitId.copy();
+      this.shortMessage = requireNonNull(shortMessage);
     }
 
     protected ParentInfo() {}
@@ -47,8 +51,8 @@
   /** List of parents of the patch set. */
   protected List<ParentInfo> parents;
 
-  /** SHA-1 of commit */
-  protected String revId;
+  /** ID of commit. */
+  protected ObjectId commitId;
 
   /** Optional user-supplied description for the patch set. */
   protected String description;
@@ -107,12 +111,12 @@
     return parents;
   }
 
-  public void setRevId(String s) {
-    revId = s;
+  public void setCommitId(AnyObjectId commitId) {
+    this.commitId = commitId.copy();
   }
 
-  public String getRevId() {
-    return revId;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public void setDescription(String description) {
diff --git a/java/com/google/gerrit/reviewdb/client/Project.java b/java/com/google/gerrit/reviewdb/client/Project.java
index 52becec..edc8e27 100644
--- a/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/java/com/google/gerrit/reviewdb/client/Project.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gwtorm.client.StringKey;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
@@ -34,50 +35,58 @@
     return new NameKey(name);
   }
 
-  /** Project name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
+  /**
+   * Project name key.
+   *
+   * <p>This class has subclasses such as {@code AllProjectsName}, which make Guice injection more
+   * convenient. Subclasses must compare equal if they have the same name, regardless of the
+   * specific class. This implies that subclasses may not add additional fields.
+   *
+   * <p>Because of this unusual subclassing behavior, this class is not an {@code @AutoValue},
+   * unlike other key types in this package. However, this is strictly an implementation detail; its
+   * interface and semantics are otherwise analogous to the {@code @AutoValue} types.
+   */
+  public static class NameKey implements Comparable<NameKey> {
+    /** Parse a Project.NameKey out of a string representation. */
+    public static NameKey parse(String str) {
+      return nameKey(KeyUtil.decode(str));
     }
 
-    @Override
+    public static String asStringOrNull(NameKey key) {
+      return key == null ? null : key.get();
+    }
+
+    private final String name;
+
+    protected NameKey(String name) {
+      this.name = requireNonNull(name);
+    }
+
     public String get() {
       return name;
     }
 
     @Override
-    protected void set(String newValue) {
-      name = newValue;
-    }
-
-    @Override
-    public int hashCode() {
+    public final int hashCode() {
       return get().hashCode();
     }
 
     @Override
-    public boolean equals(Object b) {
+    public final boolean equals(Object b) {
       if (b instanceof NameKey) {
         return get().equals(((NameKey) b).get());
       }
       return false;
     }
 
-    /** Parse a Project.NameKey out of a string representation. */
-    public static NameKey parse(String str) {
-      final NameKey r = new NameKey();
-      r.fromString(str);
-      return r;
+    @Override
+    public final int compareTo(NameKey o) {
+      return get().compareTo(o.get());
     }
 
-    public static String asStringOrNull(NameKey key) {
-      return key == null ? null : key.get();
+    @Override
+    public final String toString() {
+      return KeyUtil.encode(get());
     }
   }
 
@@ -219,7 +228,7 @@
   }
 
   public void setParentName(String n) {
-    parent = n != null ? new NameKey(n) : null;
+    parent = n != null ? nameKey(n) : null;
   }
 
   public void setParentName(NameKey n) {
diff --git a/java/com/google/gerrit/reviewdb/client/RevId.java b/java/com/google/gerrit/reviewdb/client/RevId.java
deleted file mode 100644
index 99b6c2c..0000000
--- a/java/com/google/gerrit/reviewdb/client/RevId.java
+++ /dev/null
@@ -1,73 +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;
-
-/** 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;
-
-  protected String id;
-
-  protected RevId() {}
-
-  public RevId(String str) {
-    id = str;
-  }
-
-  /** @return the value of this revision id. */
-  public String get() {
-    return id;
-  }
-
-  /** @return true if this revision id has all required digits. */
-  public boolean isComplete() {
-    return get().length() == LEN;
-  }
-
-  /**
-   * @return if {@link #isComplete()}, {@code this}; otherwise a new RevId with 'z' appended on the
-   *     end.
-   */
-  public RevId max() {
-    if (isComplete()) {
-      return this;
-    }
-
-    final StringBuilder revEnd = new StringBuilder(get().length() + 1);
-    revEnd.append(get());
-    revEnd.append('z');
-    return new RevId(revEnd.toString());
-  }
-
-  @Override
-  public int hashCode() {
-    return id.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return (o instanceof RevId) && id.equals(((RevId) o).id);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName() + "{" + id + "}";
-  }
-
-  public boolean matches(String str) {
-    return id.startsWith(str.toLowerCase());
-  }
-}
diff --git a/java/com/google/gerrit/reviewdb/client/RobotComment.java b/java/com/google/gerrit/reviewdb/client/RobotComment.java
index eceb0bf..abe475f 100644
--- a/java/com/google/gerrit/reviewdb/client/RobotComment.java
+++ b/java/com/google/gerrit/reviewdb/client/RobotComment.java
@@ -42,58 +42,12 @@
 
   @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('}')
+    return toStringHelper()
+        .add("robotId", robotId)
+        .add("robotRunId", robotRunId)
+        .add("url", url)
+        .add("properties", Objects.toString(properties, ""))
+        .add("fixSuggestions", Objects.toString(fixSuggestions, ""))
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java b/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
index b297dfb..6a451bb 100644
--- a/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
+++ b/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.StringKey;
+import java.util.Objects;
 
 /**
  * Defining a project/branch subscription to a project/branch project.
@@ -25,82 +25,48 @@
  * <p>A subscriber operates a submodule in defined path.
  */
 public final class SubmoduleSubscription {
-  /** Subscription key */
-  public static class Key extends StringKey<Branch.NameKey> {
-    private static final long serialVersionUID = 1L;
+  protected BranchNameKey superProject;
 
-    /**
-     * Indicates the super project, aka subscriber: the project owner of the gitlinks to the
-     * submodules.
-     */
-    protected Branch.NameKey superProject;
+  protected String submodulePath;
 
-    protected String submodulePath;
+  protected BranchNameKey submodule;
 
-    protected Key() {
-      superProject = new Branch.NameKey();
-    }
-
-    protected Key(Branch.NameKey superProject, String path) {
-      this.superProject = superProject;
-      this.submodulePath = path;
-    }
-
-    @Override
-    public Branch.NameKey getParentKey() {
-      return superProject;
-    }
-
-    @Override
-    public String get() {
-      return submodulePath;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      this.submodulePath = newValue;
-    }
-  }
-
-  protected Key key;
-
-  protected Branch.NameKey submodule;
-
-  protected SubmoduleSubscription() {}
-
-  public SubmoduleSubscription(Branch.NameKey superProject, Branch.NameKey submodule, String path) {
-    this.key = new Key(superProject, path);
+  public SubmoduleSubscription(BranchNameKey superProject, BranchNameKey submodule, String path) {
+    this.superProject = superProject;
     this.submodule = submodule;
+    this.submodulePath = path;
   }
 
-  public Key getKey() {
-    return key;
-  }
-
-  public Branch.NameKey getSuperProject() {
-    return key.superProject;
+  /**
+   * Indicates the super project, aka subscriber: the project owner of the gitlinks to the
+   * submodules.
+   */
+  public BranchNameKey getSuperProject() {
+    return superProject;
   }
 
   public String getPath() {
-    return key.get();
+    return submodulePath;
   }
 
-  public Branch.NameKey getSubmodule() {
+  public BranchNameKey getSubmodule() {
     return submodule;
   }
 
   @Override
   public boolean equals(Object o) {
     if (o instanceof SubmoduleSubscription) {
-      return key.equals(((SubmoduleSubscription) o).key)
-          && submodule.equals(((SubmoduleSubscription) o).submodule);
+      SubmoduleSubscription s = (SubmoduleSubscription) o;
+      return superProject.equals(s.superProject)
+          && submodulePath.equals(s.submodulePath)
+          && submodule.equals(s.submodule);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return key.hashCode();
+    return Objects.hash(superProject, submodulePath, submodule);
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
index 8dd1794..51e98c7 100644
--- a/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/AccountIdProtoConverter.java
@@ -28,7 +28,7 @@
 
   @Override
   public Account.Id fromProto(Entities.Account_Id proto) {
-    return new Account.Id(proto.getId());
+    return Account.id(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
index 4558f9b..f1018fc 100644
--- a/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverter.java
@@ -15,29 +15,29 @@
 package com.google.gerrit.reviewdb.converter;
 
 import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 
 public enum BranchNameKeyProtoConverter
-    implements ProtoConverter<Entities.Branch_NameKey, Branch.NameKey> {
+    implements ProtoConverter<Entities.Branch_NameKey, BranchNameKey> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Project_NameKey, Project.NameKey> projectNameConverter =
       ProjectNameKeyProtoConverter.INSTANCE;
 
   @Override
-  public Entities.Branch_NameKey toProto(Branch.NameKey nameKey) {
+  public Entities.Branch_NameKey toProto(BranchNameKey nameKey) {
     return Entities.Branch_NameKey.newBuilder()
-        .setProjectName(projectNameConverter.toProto(nameKey.getParentKey()))
-        .setBranchName(nameKey.get())
+        .setProject(projectNameConverter.toProto(nameKey.project()))
+        .setBranch(nameKey.branch())
         .build();
   }
 
   @Override
-  public Branch.NameKey fromProto(Entities.Branch_NameKey proto) {
-    return new Branch.NameKey(
-        projectNameConverter.fromProto(proto.getProjectName()), proto.getBranchName());
+  public BranchNameKey fromProto(Entities.Branch_NameKey proto) {
+    return BranchNameKey.create(
+        projectNameConverter.fromProto(proto.getProject()), proto.getBranch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
index 14ed59c..a89434e 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverter.java
@@ -28,7 +28,7 @@
 
   @Override
   public Change.Id fromProto(Entities.Change_Id proto) {
-    return new Change.Id(proto.getId());
+    return Change.id(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
index dccd7d9..b9a4f4d 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverter.java
@@ -28,7 +28,7 @@
 
   @Override
   public Change.Key fromProto(Entities.Change_Key proto) {
-    return new Change.Key(proto.getId());
+    return Change.key(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
index bb532df..7d97e39 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
@@ -29,14 +29,14 @@
   @Override
   public Entities.ChangeMessage_Key toProto(ChangeMessage.Key messageKey) {
     return Entities.ChangeMessage_Key.newBuilder()
-        .setChangeId(changeIdConverter.toProto(messageKey.getParentKey()))
-        .setUuid(messageKey.get())
+        .setChangeId(changeIdConverter.toProto(messageKey.changeId()))
+        .setUuid(messageKey.uuid())
         .build();
   }
 
   @Override
   public ChangeMessage.Key fromProto(Entities.ChangeMessage_Key proto) {
-    return new ChangeMessage.Key(changeIdConverter.fromProto(proto.getChangeId()), proto.getUuid());
+    return ChangeMessage.key(changeIdConverter.fromProto(proto.getChangeId()), proto.getUuid());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
index 30b6d27..384dbca 100644
--- a/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeProtoConverter.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.protobuf.Parser;
@@ -31,7 +31,7 @@
       ChangeKeyProtoConverter.INSTANCE;
   private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
       AccountIdProtoConverter.INSTANCE;
-  private final ProtoConverter<Entities.Branch_NameKey, Branch.NameKey> branchNameConverter =
+  private final ProtoConverter<Entities.Branch_NameKey, BranchNameKey> branchNameConverter =
       BranchNameKeyProtoConverter.INSTANCE;
 
   @Override
@@ -86,7 +86,7 @@
         proto.hasChangeKey() ? changeKeyConverter.fromProto(proto.getChangeKey()) : null;
     Account.Id owner =
         proto.hasOwnerAccountId() ? accountIdConverter.fromProto(proto.getOwnerAccountId()) : null;
-    Branch.NameKey destination =
+    BranchNameKey destination =
         proto.hasDest() ? branchNameConverter.fromProto(proto.getDest()) : null;
     Change change =
         new Change(key, changeId, owner, destination, new Timestamp(proto.getCreatedOn()));
@@ -100,7 +100,7 @@
     String subject = proto.hasSubject() ? proto.getSubject() : null;
     String originalSubject = proto.hasOriginalSubject() ? proto.getOriginalSubject() : null;
     change.setCurrentPatchSet(
-        new PatchSet.Id(changeId, proto.getCurrentPatchSetId()), subject, originalSubject);
+        PatchSet.id(changeId, proto.getCurrentPatchSetId()), subject, originalSubject);
     if (proto.hasTopic()) {
       change.setTopic(proto.getTopic());
     }
diff --git a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
index 274f23b..7bc2ab1 100644
--- a/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/LabelIdProtoConverter.java
@@ -28,7 +28,7 @@
 
   @Override
   public LabelId fromProto(Entities.LabelId proto) {
-    return new LabelId(proto.getId());
+    return LabelId.create(proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java
new file mode 100644
index 0000000..7413af9
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverter.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Proto converter for {@code ObjectId}s.
+ *
+ * <p>This converter uses the hex representation of object IDs embedded in a wrapper proto type,
+ * rather than a more parsimonious implementation (e.g. a raw byte array), for two reasons:
+ *
+ * <ul>
+ *   <li>Hex strings are easier to read and work with when reading and writing protos in text
+ *       formats, for example in test failure messages, or when using command-line tools.
+ *   <li>This maintains backwards wire compatibility with a pre-NoteDb implementation.
+ * </ul>
+ */
+public enum ObjectIdProtoConverter implements ProtoConverter<Entities.ObjectId, ObjectId> {
+  INSTANCE;
+
+  @Override
+  public Entities.ObjectId toProto(ObjectId objectId) {
+    return Entities.ObjectId.newBuilder().setName(objectId.name()).build();
+  }
+
+  @Override
+  public ObjectId fromProto(Entities.ObjectId proto) {
+    return ObjectId.fromString(proto.getName());
+  }
+
+  @Override
+  public Parser<Entities.ObjectId> getParser() {
+    return Entities.ObjectId.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
index 3538301..43f6295 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverter.java
@@ -35,18 +35,18 @@
   @Override
   public Entities.PatchSetApproval_Key toProto(PatchSetApproval.Key key) {
     return Entities.PatchSetApproval_Key.newBuilder()
-        .setPatchSetId(patchSetIdConverter.toProto(key.getParentKey()))
-        .setAccountId(accountIdConverter.toProto(key.getAccountId()))
-        .setCategoryId(labelIdConverter.toProto(key.getLabelId()))
+        .setPatchSetId(patchSetIdConverter.toProto(key.patchSetId()))
+        .setAccountId(accountIdConverter.toProto(key.accountId()))
+        .setLabelId(labelIdConverter.toProto(key.labelId()))
         .build();
   }
 
   @Override
   public PatchSetApproval.Key fromProto(Entities.PatchSetApproval_Key proto) {
-    return new PatchSetApproval.Key(
+    return PatchSetApproval.key(
         patchSetIdConverter.fromProto(proto.getPatchSetId()),
         accountIdConverter.fromProto(proto.getAccountId()),
-        labelIdConverter.fromProto(proto.getCategoryId()));
+        labelIdConverter.fromProto(proto.getLabelId()));
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
index 418076f..3baec99 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverter.java
@@ -34,21 +34,18 @@
   public Entities.PatchSetApproval toProto(PatchSetApproval patchSetApproval) {
     Entities.PatchSetApproval.Builder builder =
         Entities.PatchSetApproval.newBuilder()
-            .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.getKey()))
-            .setValue(patchSetApproval.getValue())
-            .setGranted(patchSetApproval.getGranted().getTime())
-            .setPostSubmit(patchSetApproval.isPostSubmit());
+            .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.key()))
+            .setValue(patchSetApproval.value())
+            .setGranted(patchSetApproval.granted().getTime())
+            .setPostSubmit(patchSetApproval.postSubmit());
 
-    String tag = patchSetApproval.getTag();
-    if (tag != null) {
-      builder.setTag(tag);
-    }
-    Account.Id realAccountId = patchSetApproval.getRealAccountId();
+    patchSetApproval.tag().ifPresent(builder::setTag);
+    Account.Id realAccountId = patchSetApproval.realAccountId();
     // PatchSetApproval#getRealAccountId automatically delegates to PatchSetApproval#getAccountId if
     // the real author is not set. However, the previous protobuf representation kept
     // 'realAccountId' empty if it wasn't set. To ensure binary compatibility, simulate the previous
     // behavior.
-    if (realAccountId != null && !Objects.equals(realAccountId, patchSetApproval.getAccountId())) {
+    if (realAccountId != null && !Objects.equals(realAccountId, patchSetApproval.accountId())) {
       builder.setRealAccountId(accountIdConverter.toProto(realAccountId));
     }
 
@@ -57,21 +54,19 @@
 
   @Override
   public PatchSetApproval fromProto(Entities.PatchSetApproval proto) {
-    PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()),
-            (short) proto.getValue(),
-            new Timestamp(proto.getGranted()));
+    PatchSetApproval.Builder builder =
+        PatchSetApproval.builder()
+            .key(patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()))
+            .value(proto.getValue())
+            .granted(new Timestamp(proto.getGranted()))
+            .postSubmit(proto.getPostSubmit());
     if (proto.hasTag()) {
-      patchSetApproval.setTag(proto.getTag());
+      builder.tag(proto.getTag());
     }
     if (proto.hasRealAccountId()) {
-      patchSetApproval.setRealAccountId(accountIdConverter.fromProto(proto.getRealAccountId()));
+      builder.realAccountId(accountIdConverter.fromProto(proto.getRealAccountId()));
     }
-    if (proto.hasPostSubmit()) {
-      patchSetApproval.setPostSubmit(proto.getPostSubmit());
-    }
-    return patchSetApproval;
+    return builder.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
index a2b95bd..4101a6b 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverter.java
@@ -28,14 +28,14 @@
   @Override
   public Entities.PatchSet_Id toProto(PatchSet.Id patchSetId) {
     return Entities.PatchSet_Id.newBuilder()
-        .setChangeId(changeIdConverter.toProto(patchSetId.getParentKey()))
-        .setPatchSetId(patchSetId.get())
+        .setChangeId(changeIdConverter.toProto(patchSetId.changeId()))
+        .setId(patchSetId.get())
         .build();
   }
 
   @Override
   public PatchSet.Id fromProto(Entities.PatchSet_Id proto) {
-    return new PatchSet.Id(changeIdConverter.fromProto(proto.getChangeId()), proto.getPatchSetId());
+    return PatchSet.id(changeIdConverter.fromProto(proto.getChangeId()), proto.getId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
index 75ee800..f9ed8ef 100644
--- a/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/PatchSetProtoConverter.java
@@ -14,76 +14,77 @@
 
 package com.google.gerrit.reviewdb.converter;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.protobuf.Parser;
 import java.sql.Timestamp;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
   INSTANCE;
 
   private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
       PatchSetIdProtoConverter.INSTANCE;
-  private final ProtoConverter<Entities.RevId, RevId> revIdConverter = RevIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.ObjectId, ObjectId> objectIdConverter =
+      ObjectIdProtoConverter.INSTANCE;
   private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
       AccountIdProtoConverter.INSTANCE;
 
   @Override
   public Entities.PatchSet toProto(PatchSet patchSet) {
     Entities.PatchSet.Builder builder =
-        Entities.PatchSet.newBuilder().setId(patchSetIdConverter.toProto(patchSet.getId()));
-    RevId revision = patchSet.getRevision();
-    if (revision != null) {
-      builder.setRevision(revIdConverter.toProto(revision));
-    }
-    Account.Id uploader = patchSet.getUploader();
-    if (uploader != null) {
-      builder.setUploaderAccountId(accountIdConverter.toProto(uploader));
-    }
-    Timestamp createdOn = patchSet.getCreatedOn();
-    if (createdOn != null) {
-      builder.setCreatedOn(createdOn.getTime());
-    }
-    List<String> groups = patchSet.getGroups();
+        Entities.PatchSet.newBuilder()
+            .setId(patchSetIdConverter.toProto(patchSet.id()))
+            .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
+            .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
+            .setCreatedOn(patchSet.createdOn().getTime());
+    List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
       builder.setGroups(PatchSet.joinGroups(groups));
     }
-    String pushCertificate = patchSet.getPushCertificate();
-    if (pushCertificate != null) {
-      builder.setPushCertificate(pushCertificate);
-    }
-    String description = patchSet.getDescription();
-    if (description != null) {
-      builder.setDescription(description);
-    }
+    patchSet.pushCertificate().ifPresent(builder::setPushCertificate);
+    patchSet.description().ifPresent(builder::setDescription);
     return builder.build();
   }
 
   @Override
   public PatchSet fromProto(Entities.PatchSet proto) {
-    PatchSet patchSet = new PatchSet(patchSetIdConverter.fromProto(proto.getId()));
-    if (proto.hasRevision()) {
-      patchSet.setRevision(revIdConverter.fromProto(proto.getRevision()));
-    }
-    if (proto.hasUploaderAccountId()) {
-      patchSet.setUploader(accountIdConverter.fromProto(proto.getUploaderAccountId()));
-    }
-    if (proto.hasCreatedOn()) {
-      patchSet.setCreatedOn(new Timestamp(proto.getCreatedOn()));
-    }
-    if (proto.hasGroups()) {
-      patchSet.setGroups(PatchSet.splitGroups(proto.getGroups()));
-    }
+    PatchSet.Builder builder =
+        PatchSet.builder()
+            .id(patchSetIdConverter.fromProto(proto.getId()))
+            .groups(
+                proto.hasGroups() ? PatchSet.splitGroups(proto.getGroups()) : ImmutableList.of());
     if (proto.hasPushCertificate()) {
-      patchSet.setPushCertificate(proto.getPushCertificate());
+      builder.pushCertificate(proto.getPushCertificate());
     }
     if (proto.hasDescription()) {
-      patchSet.setDescription(proto.getDescription());
+      builder.description(proto.getDescription());
     }
-    return patchSet;
+
+    // The following fields used to theoretically be nullable in PatchSet, but in practice no
+    // production codepath should have ever serialized an instance that was missing one of these
+    // fields.
+    //
+    // However, since some protos may theoretically be missing these fields, we need to support
+    // them. Populate specific sentinel values for each field as documented in the PatchSet javadoc.
+    // Callers that encounter one of these sentinels will likely fail, for example by failing to
+    // look up the zeroId. They would have also failed back when the fields were nullable, for
+    // example with NPE; the current behavior just fails slightly differently.
+    builder
+        .commitId(
+            proto.hasCommitId()
+                ? objectIdConverter.fromProto(proto.getCommitId())
+                : ObjectId.zeroId())
+        .uploader(
+            proto.hasUploaderAccountId()
+                ? accountIdConverter.fromProto(proto.getUploaderAccountId())
+                : Account.id(0))
+        .createdOn(proto.hasCreatedOn() ? new Timestamp(proto.getCreatedOn()) : new Timestamp(0));
+
+    return builder.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
index f7d809e..74849af 100644
--- a/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverter.java
@@ -29,7 +29,7 @@
 
   @Override
   public Project.NameKey fromProto(Entities.Project_NameKey proto) {
-    return new Project.NameKey(proto.getName());
+    return Project.nameKey(proto.getName());
   }
 
   @Override
diff --git a/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java
deleted file mode 100644
index b3c998b..0000000
--- a/java/com/google/gerrit/reviewdb/converter/RevIdProtoConverter.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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.reviewdb.converter;
-
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.protobuf.Parser;
-
-public enum RevIdProtoConverter implements ProtoConverter<Entities.RevId, RevId> {
-  INSTANCE;
-
-  @Override
-  public Entities.RevId toProto(RevId revId) {
-    return Entities.RevId.newBuilder().setId(revId.get()).build();
-  }
-
-  @Override
-  public RevId fromProto(Entities.RevId proto) {
-    return new RevId(proto.getId());
-  }
-
-  @Override
-  public Parser<Entities.RevId> getParser() {
-    return Entities.RevId.parser();
-  }
-}
diff --git a/java/com/google/gerrit/server/ApprovalCopier.java b/java/com/google/gerrit/server/ApprovalCopier.java
index a1df711..979cc11 100644
--- a/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/ApprovalCopier.java
@@ -41,7 +41,6 @@
 import java.util.List;
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -99,19 +98,19 @@
     requireNonNull(ps, "ps should not be null");
     ChangeData cd = changeDataFactory.create(notes);
     try {
-      ProjectState project = projectCache.checkedGet(cd.change().getDest().getParentKey());
+      ProjectState project = projectCache.checkedGet(cd.change().getDest().project());
       ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
       requireNonNull(all, "all should not be null");
 
       Table<String, Account.Id, PatchSetApproval> wontCopy = HashBasedTable.create();
       for (PatchSetApproval psa : dontCopy) {
-        wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+        wontCopy.put(psa.label(), psa.accountId(), psa);
       }
 
       Table<String, Account.Id, PatchSetApproval> byUser = HashBasedTable.create();
-      for (PatchSetApproval psa : all.get(ps.getId())) {
-        if (!wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
-          byUser.put(psa.getLabel(), psa.getAccountId(), psa);
+      for (PatchSetApproval psa : all.get(ps.id())) {
+        if (!wontCopy.contains(psa.label(), psa.accountId())) {
+          byUser.put(psa.label(), psa.accountId(), psa);
         }
       }
 
@@ -119,33 +118,29 @@
 
       // Walk patch sets strictly less than current in descending order.
       Collection<PatchSet> allPrior =
-          patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
+          patchSets.descendingMap().tailMap(ps.id().get(), false).values();
       for (PatchSet priorPs : allPrior) {
-        List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
+        List<PatchSetApproval> priorApprovals = all.get(priorPs.id());
         if (priorApprovals.isEmpty()) {
           continue;
         }
 
         ChangeKind kind =
             changeKindCache.getChangeKind(
-                project.getNameKey(),
-                rw,
-                repoConfig,
-                ObjectId.fromString(priorPs.getRevision().get()),
-                ObjectId.fromString(ps.getRevision().get()));
+                project.getNameKey(), rw, repoConfig, priorPs.commitId(), ps.commitId());
 
         for (PatchSetApproval psa : priorApprovals) {
-          if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
+          if (wontCopy.contains(psa.label(), psa.accountId())) {
             continue;
           }
-          if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
+          if (byUser.contains(psa.label(), psa.accountId())) {
             continue;
           }
-          if (!canCopy(project, psa, ps.getId(), kind)) {
-            wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+          if (!canCopy(project, psa, ps.id(), kind)) {
+            wontCopy.put(psa.label(), psa.accountId(), psa);
             continue;
           }
-          byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
+          byUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
         }
       }
       return labelNormalizer.normalize(notes, byUser.values()).getNormalized();
@@ -158,16 +153,16 @@
     Collection<PatchSet> patchSets = cd.patchSets();
     TreeMap<Integer, PatchSet> result = new TreeMap<>();
     for (PatchSet ps : patchSets) {
-      result.put(ps.getId().get(), ps);
+      result.put(ps.id().get(), ps);
     }
     return result;
   }
 
   private static boolean canCopy(
       ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
-    int n = psa.getKey().getParentKey().get();
+    int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.getLabelId());
+    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
     if (type == null) {
       return false;
     } else if ((type.isCopyMinScore() && type.isMaxNegative(psa))
@@ -191,11 +186,4 @@
         return false;
     }
   }
-
-  private static PatchSetApproval copy(PatchSetApproval src, PatchSet.Id psId) {
-    if (src.getKey().getParentKey().equals(psId)) {
-      return src;
-    }
-    return new PatchSetApproval(psId, src);
-  }
 }
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 865e33c..9befb46 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -25,7 +25,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -71,27 +70,25 @@
  * 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 FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static PatchSetApproval newApproval(
+  public static PatchSetApproval.Builder 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;
+    PatchSetApproval.Builder b =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
+            .value(value)
+            .granted(when);
+    user.updateRealAccountId(b::realAccountId);
+    return b;
   }
 
   private static Iterable<PatchSetApproval> filterApprovals(
       Iterable<PatchSetApproval> psas, Account.Id accountId) {
-    return Iterables.filter(psas, a -> Objects.equals(a.getAccountId(), accountId));
+    return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
   private final ApprovalCopier copier;
@@ -149,7 +146,7 @@
         update,
         labelTypes,
         change,
-        ps.getId(),
+        ps.id(),
         info.getAuthor().getAccount(),
         info.getCommitter().getAccount(),
         wantReviewers,
@@ -209,8 +206,11 @@
     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()));
+          PatchSetApproval.builder()
+              .key(PatchSetApproval.key(psId, account, labelId))
+              .value(0)
+              .granted(update.getWhen())
+              .build());
       update.putReviewer(account, REVIEWER);
     }
     return Collections.unmodifiableList(cells);
@@ -276,10 +276,10 @@
       throws RestApiException, PermissionBackendException {
     Account.Id accountId = user.getAccountId();
     checkArgument(
-        accountId.equals(ps.getUploader()),
+        accountId.equals(ps.uploader()),
         "expected user %s to match patch set uploader %s",
         accountId,
-        ps.getUploader());
+        ps.uploader());
     if (approvals.isEmpty()) {
       return ImmutableList.of();
     }
@@ -288,10 +288,10 @@
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.getId(), user, lt.getLabelId(), vote.getValue(), ts));
+      cells.add(newApproval(ps.id(), user, lt.getLabelId(), vote.getValue(), ts).build());
     }
     for (PatchSetApproval psa : cells) {
-      update.putApproval(psa.getLabel(), psa.getValue());
+      update.putApproval(psa.label(), psa.value());
     }
     return cells;
   }
@@ -359,8 +359,8 @@
     }
     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 (a.patchSetId().equals(c) && a.value() > 0 && a.isLegacySubmit()) {
+        if (submitter == null || a.granted().compareTo(submitter.granted()) > 0) {
           submitter = a;
         }
       }
@@ -374,7 +374,7 @@
     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()).value() == e.getValue()) {
           continue;
         }
         if (first) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 34e9f3a..22d8eb9 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -53,7 +53,6 @@
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/ssl",
-        "//java/com/google/gwtorm",
         "//java/org/apache/commons/net",
         "//java/org/eclipse/jgit:server",
         "//lib:args4j",
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 97ba8f0..7b0a66f 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -72,10 +72,7 @@
     Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
     ChangeMessage m =
         new ChangeMessage(
-            new ChangeMessage.Key(psId.getParentKey(), ChangeUtil.messageUuid()),
-            accountId,
-            when,
-            psId);
+            ChangeMessage.key(psId.changeId(), ChangeUtil.messageUuid()), accountId, when, psId);
     m.setMessage(body);
     m.setTag(tag);
     user.updateRealAccountId(m::setRealAuthor);
@@ -127,7 +124,7 @@
       ChangeMessage message, AccountLoader accountLoader) {
     PatchSet.Id patchNum = message.getPatchSetId();
     ChangeMessageInfo cmi = new ChangeMessageInfo();
-    cmi.id = message.getKey().get();
+    cmi.id = message.getKey().uuid();
     cmi.author = accountLoader.get(message.getAuthor());
     cmi.date = message.getWrittenOn();
     cmi.message = message.getMessage();
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index b8a00f4..8c207a8 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -40,7 +40,7 @@
   private static final BaseEncoding UUID_ENCODING = BaseEncoding.base16().lowerCase();
 
   public static final Ordering<PatchSet> PS_ID_ORDER =
-      Ordering.from(comparingInt(PatchSet::getPatchSetId));
+      Ordering.from(comparingInt(PatchSet::number));
 
   /** @return a new unique identifier for change message entities. */
   public static String messageUuid() {
@@ -83,7 +83,7 @@
     Set<PatchSet.Id> existing =
         changeRefNames
             .map(PatchSet.Id::fromRef)
-            .filter(psId -> psId != null && psId.getParentKey().equals(id.getParentKey()))
+            .filter(psId -> psId != null && psId.changeId().equals(id.changeId()))
             .collect(toSet());
     PatchSet.Id next = nextPatchSetId(id);
     while (existing.contains(next)) {
@@ -103,7 +103,7 @@
    * @return next patch set ID for the same change, incrementing by 1.
    */
   public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
-    return new PatchSet.Id(id.getParentKey(), id.get() + 1);
+    return PatchSet.id(id.changeId(), id.get() + 1);
   }
 
   /**
@@ -116,7 +116,7 @@
    */
   public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id) throws IOException {
     return nextPatchSetIdFromChangeRefs(
-        git.getRefDatabase().getRefsByPrefix(id.getParentKey().toRefPrefix()).stream()
+        git.getRefDatabase().getRefsByPrefix(id.changeId().toRefPrefix()).stream()
             .map(Ref::getName),
         id);
   }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index a5332eb..449d61b 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -50,7 +50,6 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -92,7 +91,7 @@
       };
 
   public static PatchSet.Id getCommentPsId(Change.Id changeId, Comment comment) {
-    return new PatchSet.Id(changeId, comment.key.patchSetId);
+    return PatchSet.id(changeId, comment.key.patchSetId);
   }
 
   public static String extractMessageId(@Nullable String tag) {
@@ -131,7 +130,7 @@
         unresolved = false;
       } else {
         // Inherit unresolved value from inReplyTo comment if not specified.
-        Comment.Key key = new Comment.Key(parentUuid, path, psId.patchSetId);
+        Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
         Optional<Comment> parent = getPublished(ctx.getNotes(), key);
         if (!parent.isPresent()) {
           throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
@@ -306,22 +305,22 @@
     return sort(result);
   }
 
-  public static void setCommentRevId(Comment c, PatchListCache cache, Change change, PatchSet ps)
+  public static void setCommentCommitId(Comment c, PatchListCache cache, Change change, PatchSet ps)
       throws PatchListNotAvailableException {
     checkArgument(
-        c.key.patchSetId == ps.getId().get(),
-        "cannot set RevId for patch set %s on comment %s",
-        ps.getId(),
+        c.key.patchSetId == ps.id().get(),
+        "cannot set commit ID for patch set %s on comment %s",
+        ps.id(),
         c);
-    if (c.revId == null) {
+    if (c.getCommitId() == null) {
       if (Side.fromShort(c.side) == Side.PARENT) {
         if (c.side < 0) {
-          c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
+          c.setCommitId(cache.getOldId(change, ps, -c.side));
         } else {
-          c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
+          c.setCommitId(cache.getOldId(change, ps, null));
         }
       } else {
-        c.revId = ps.getRevision().get();
+        c.setCommitId(ps.commitId());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 2a78eb6..7e5b90c 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -27,7 +28,6 @@
 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.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -39,6 +39,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -89,8 +90,8 @@
       PatchSet.Id psId,
       ObjectId commit,
       List<String> groups,
-      String pushCertificate,
-      String description)
+      @Nullable String pushCertificate,
+      @Nullable String description)
       throws IOException {
     requireNonNull(groups, "groups may not be null");
     ensurePatchSetMatches(psId, update);
@@ -99,20 +100,21 @@
     update.setPsDescription(description);
     update.setGroups(groups);
 
-    PatchSet ps = new PatchSet(psId);
-    ps.setRevision(new RevId(commit.name()));
-    ps.setUploader(update.getAccountId());
-    ps.setCreatedOn(new Timestamp(update.getWhen().getTime()));
-    ps.setGroups(groups);
-    ps.setPushCertificate(pushCertificate);
-    ps.setDescription(description);
-    return ps;
+    return PatchSet.builder()
+        .id(psId)
+        .commitId(commit)
+        .uploader(update.getAccountId())
+        .createdOn(new Timestamp(update.getWhen().getTime()))
+        .groups(groups)
+        .pushCertificate(Optional.ofNullable(pushCertificate))
+        .description(Optional.ofNullable(description))
+        .build();
   }
 
   private static void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
-    Change.Id changeId = update.getChange().getId();
+    Change.Id changeId = update.getId();
     checkArgument(
-        psId.getParentKey().equals(changeId),
+        psId.changeId().equals(changeId),
         "cannot modify patch set %s on update for change %s",
         psId,
         changeId);
@@ -127,11 +129,6 @@
     }
   }
 
-  public void setGroups(ChangeUpdate update, PatchSet ps, List<String> groups) {
-    ps.setGroups(groups);
-    update.setGroups(groups);
-  }
-
   /** Check if the current patch set of the change is locked. */
   public void checkPatchSetNotLocked(ChangeNotes notes)
       throws IOException, ResourceConflictException {
@@ -155,10 +152,8 @@
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
     for (PatchSetApproval ap :
         approvalsUtil.byPatchSet(notes, change.currentPatchSetId(), null, null)) {
-      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.getLabel());
-      if (type != null
-          && ap.getValue() == 1
-          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
+      if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
@@ -169,7 +164,7 @@
   public RevCommit getRevCommit(Project.NameKey project, PatchSet patchSet) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+      RevCommit src = rw.parseCommit(patchSet.commitId());
       rw.parseBody(src);
       return src;
     }
diff --git a/java/com/google/gerrit/server/ProjectUtil.java b/java/com/google/gerrit/server/ProjectUtil.java
index 1db4aa3..490c143 100644
--- a/java/com/google/gerrit/server/ProjectUtil.java
+++ b/java/com/google/gerrit/server/ProjectUtil.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
@@ -33,13 +33,14 @@
    * @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, Branch.NameKey branch)
+  public static boolean branchExists(final GitRepositoryManager repoManager, BranchNameKey branch)
       throws RepositoryNotFoundException, IOException {
-    try (Repository repo = repoManager.openRepository(branch.getParentKey())) {
-      boolean exists = repo.getRefDatabase().exactRef(branch.get()) != null;
+    try (Repository repo = repoManager.openRepository(branch.project())) {
+      boolean exists = repo.getRefDatabase().exactRef(branch.branch()) != null;
       if (!exists) {
         exists =
-            repo.getFullBranch().equals(branch.get()) || RefNames.REFS_CONFIG.equals(branch.get());
+            repo.getFullBranch().equals(branch.branch())
+                || RefNames.REFS_CONFIG.equals(branch.branch());
       }
       return exists;
     }
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index ad93ef0..8ae5bcd 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -66,7 +66,7 @@
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
       ctx.getUser().updateRealAccountId(d::setRealAuthor);
       try {
-        CommentsUtil.setCommentRevId(d, patchListCache, notes.getChange(), ps);
+        CommentsUtil.setCommentCommitId(d, patchListCache, notes.getChange(), ps);
       } catch (PatchListNotAvailableException e) {
         throw new StorageException(e);
       }
@@ -75,6 +75,6 @@
   }
 
   private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
-    return new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+    return PatchSet.id(notes.getChangeId(), c.key.patchSetId);
   }
 }
diff --git a/java/com/google/gerrit/server/ReviewerSet.java b/java/com/google/gerrit/server/ReviewerSet.java
index f36e3ab..f0bc23e 100644
--- a/java/com/google/gerrit/server/ReviewerSet.java
+++ b/java/com/google/gerrit/server/ReviewerSet.java
@@ -44,18 +44,14 @@
         first = psa;
       } else {
         checkArgument(
-            first
-                .getKey()
-                .getParentKey()
-                .getParentKey()
-                .equals(psa.getKey().getParentKey().getParentKey()),
+            first.key().patchSetId().changeId().equals(psa.key().patchSetId().changeId()),
             "multiple change IDs: %s, %s",
-            first.getKey(),
-            psa.getKey());
+            first.key(),
+            psa.key());
       }
-      Account.Id id = psa.getAccountId();
-      reviewers.put(REVIEWER, id, psa.getGranted());
-      if (psa.getValue() != 0) {
+      Account.Id id = psa.accountId();
+      reviewers.put(REVIEWER, id, psa.granted());
+      if (psa.value() != 0) {
         reviewers.remove(CC, id);
       }
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index ad3bfd6..d092ac8 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -88,7 +88,7 @@
         if (id == null) {
           return null;
         }
-        Account.Id accountId = new Account.Id(id);
+        Account.Id accountId = Account.id(id);
         String label = s.substring(p + 1);
         return create(accountId, label);
       }
@@ -233,7 +233,16 @@
     }
   }
 
-  public void unstarAll(Project.NameKey project, Change.Id changeId) {
+  /**
+   * Unstar the given change for all users.
+   *
+   * <p>Intended for use only when we're about to delete a change. For that reason, the change is
+   * not reindexed.
+   *
+   * @param changeId change ID.
+   * @throws IOException if an error occurred.
+   */
+  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
@@ -254,9 +263,6 @@
                   changeId.get(), command.getRefName(), command.getResult()));
         }
       }
-      indexer.index(project, changeId);
-    } catch (IOException e) {
-      throw new StorageException(String.format("Unstar change %d failed", changeId.get()), e);
     }
   }
 
@@ -268,7 +274,7 @@
         if (id == null) {
           continue;
         }
-        Account.Id accountId = new Account.Id(id);
+        Account.Id accountId = Account.id(id);
         builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 589344c..94bf53c 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
@@ -35,8 +36,8 @@
 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 java.util.function.Function;
+import java.util.function.Predicate;
 
 @Singleton
 public class WebLinks {
@@ -87,7 +88,7 @@
    * @param commit SHA1 of commit.
    * @return Links for patch sets.
    */
-  public List<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
+  public ImmutableList<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
     return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
   }
 
@@ -96,7 +97,7 @@
    * @param revision SHA1 of the parent revision.
    * @return Links for patch sets.
    */
-  public List<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
+  public ImmutableList<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
     return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
   }
 
@@ -106,9 +107,9 @@
    * @param file File name.
    * @return Links for files.
    */
-  public List<WebLinkInfo> getFileLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
     return Patch.isMagic(file)
-        ? Collections.emptyList()
+        ? ImmutableList.of()
         : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
   }
 
@@ -118,14 +119,15 @@
    * @param file File name.
    * @return Links for file history
    */
-  public List<WebLinkInfo> getFileHistoryLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileHistoryLinks(
+      String project, String revision, String file) {
     if (Patch.isMagic(file)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
-    return FluentIterable.from(fileHistoryLinks)
-        .transform(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
+    return Streams.stream(fileHistoryLinks)
+        .map(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
         .filter(INVALID_WEBLINK)
-        .toList();
+        .collect(toImmutableList());
   }
 
   /**
@@ -138,20 +140,20 @@
    * @param fileB File name of side B.
    * @return Links for file diffs.
    */
-  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) {
+  public ImmutableList<DiffWebLinkInfo> getDiffLinks(
+      String project,
+      int changeId,
+      Integer patchSetIdA,
+      String revisionA,
+      String fileA,
+      int patchSetIdB,
+      String revisionB,
+      String fileB) {
     if (Patch.isMagic(fileA) || Patch.isMagic(fileB)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
-    return FluentIterable.from(diffLinks)
-        .transform(
+    return Streams.stream(diffLinks)
+        .map(
             webLink ->
                 webLink.getDiffLink(
                     project,
@@ -163,14 +165,14 @@
                     revisionB,
                     fileB))
         .filter(INVALID_WEBLINK)
-        .toList();
+        .collect(toImmutableList());
   }
 
   /**
    * @param project Project name.
    * @return Links for projects.
    */
-  public List<WebLinkInfo> getProjectLinks(String project) {
+  public ImmutableList<WebLinkInfo> getProjectLinks(String project) {
     return filterLinks(projectLinks, webLink -> webLink.getProjectWeblink(project));
   }
 
@@ -179,7 +181,7 @@
    * @param branch Branch name
    * @return Links for branches.
    */
-  public List<WebLinkInfo> getBranchLinks(String project, String branch) {
+  public ImmutableList<WebLinkInfo> getBranchLinks(String project, String branch) {
     return filterLinks(branchLinks, webLink -> webLink.getBranchWebLink(project, branch));
   }
 
@@ -188,12 +190,15 @@
    * @param tag Tag name
    * @return Links for tags.
    */
-  public List<WebLinkInfo> getTagLinks(String project, String tag) {
+  public ImmutableList<WebLinkInfo> getTagLinks(String project, String tag) {
     return filterLinks(tagLinks, webLink -> webLink.getTagWebLink(project, tag));
   }
 
-  private <T extends WebLink> List<WebLinkInfo> filterLinks(
+  private <T extends WebLink> ImmutableList<WebLinkInfo> filterLinks(
       DynamicSet<T> links, Function<T, WebLinkInfo> transformer) {
-    return FluentIterable.from(links).transform(transformer).filter(INVALID_WEBLINK).toList();
+    return Streams.stream(links)
+        .map(transformer)
+        .filter(INVALID_WEBLINK)
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index 4398d9e..09b9ac3 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
@@ -67,7 +68,8 @@
     provided = new ArrayList<>();
   }
 
-  public synchronized AccountInfo get(Account.Id id) {
+  @Nullable
+  public synchronized AccountInfo get(@Nullable Account.Id id) {
     if (id == null) {
       return null;
     }
@@ -95,7 +97,8 @@
     fill();
   }
 
-  public AccountInfo fillOne(Account.Id id) throws PermissionBackendException {
+  @Nullable
+  public AccountInfo fillOne(@Nullable Account.Id id) throws PermissionBackendException {
     AccountInfo info = get(id);
     fill();
     return info;
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index eb6b491..7e49c10 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -272,7 +272,7 @@
 
   private AuthResult create(AuthRequest who)
       throws AccountException, IOException, ConfigInvalidException {
-    Account.Id newId = new Account.Id(sequences.nextAccountId());
+    Account.Id newId = Account.id(sequences.nextAccountId());
     logger.atFine().log("Assigning new Id %s to account", newId);
 
     ExternalId extId =
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 1854dc1..46fde8c 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
-import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
@@ -54,9 +53,6 @@
 public class AccountState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
-      a -> a.getAccount().getId();
-
   /**
    * Creates an AccountState from the given account config.
    *
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index 5bcb84b..573baee 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -34,7 +34,7 @@
   }
 
   public void setGroupName(String n) {
-    groupName = n != null ? new AccountGroup.NameKey(n) : null;
+    groupName = n != null ? AccountGroup.nameKey(n) : null;
   }
 
   public void setGroupName(AccountGroup.NameKey n) {
diff --git a/java/com/google/gerrit/server/account/DestinationList.java b/java/com/google/gerrit/server/account/DestinationList.java
index 04e710a..8f4e72e 100644
--- a/java/com/google/gerrit/server/account/DestinationList.java
+++ b/java/com/google/gerrit/server/account/DestinationList.java
@@ -18,7 +18,7 @@
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.TabFile;
@@ -28,10 +28,10 @@
 
 public class DestinationList extends TabFile {
   public static final String DIR_NAME = "destinations";
-  private SetMultimap<String, Branch.NameKey> destinations =
+  private SetMultimap<String, BranchNameKey> destinations =
       MultimapBuilder.hashKeys().hashSetValues().build();
 
-  public Set<Branch.NameKey> getDestinations(String label) {
+  public Set<BranchNameKey> getDestinations(String label) {
     return destinations.get(label);
   }
 
@@ -40,21 +40,21 @@
   }
 
   String asText(String label) {
-    Set<Branch.NameKey> dests = destinations.get(label);
+    Set<BranchNameKey> dests = destinations.get(label);
     if (dests == null) {
       return null;
     }
     List<Row> rows = Lists.newArrayListWithCapacity(dests.size());
-    for (Branch.NameKey dest : sort(dests)) {
-      rows.add(new Row(dest.get(), dest.getParentKey().get()));
+    for (BranchNameKey dest : sort(dests)) {
+      rows.add(new Row(dest.branch(), dest.project().get()));
     }
     return asText("Ref", "Project", rows);
   }
 
-  private static Set<Branch.NameKey> toSet(List<Row> destRows) {
-    Set<Branch.NameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
+  private static Set<BranchNameKey> toSet(List<Row> destRows) {
+    Set<BranchNameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
     for (Row row : destRows) {
-      dests.add(new Branch.NameKey(new Project.NameKey(row.right), row.left));
+      dests.add(BranchNameKey.create(Project.nameKey(row.right), row.left));
     }
     return dests;
   }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index c85e2df..b9cfb61 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -166,7 +166,7 @@
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
       try (TraceTimer timer = TraceContext.newTimer("Loading group '%s' by name", name)) {
-        return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
+        return groupQueryProvider.get().byName(AccountGroup.nameKey(name));
       }
     }
   }
@@ -182,7 +182,7 @@
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
       try (TraceTimer timer = TraceContext.newTimer("Loading group %s by UUID", uuid)) {
-        return groups.getGroup(new AccountGroup.UUID(uuid));
+        return groups.getGroup(AccountGroup.uuid(uuid));
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupUUID.java b/java/com/google/gerrit/server/account/GroupUUID.java
index a7b32a1..5bb9d57 100644
--- a/java/com/google/gerrit/server/account/GroupUUID.java
+++ b/java/com/google/gerrit/server/account/GroupUUID.java
@@ -26,7 +26,7 @@
     md.update(Constants.encode("group " + groupName + "\n"));
     md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
     md.update(Constants.encode(String.valueOf(Math.random())));
-    return new AccountGroup.UUID(ObjectId.fromRaw(md.digest()).name());
+    return AccountGroup.uuid(ObjectId.fromRaw(md.digest()).name());
   }
 
   private GroupUUID() {}
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index ce97ff9..49afaf2 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -98,11 +98,10 @@
 
     Set<FillOptions> fillOptionsWithoutSecondaryEmails =
         Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS));
-    Set<Account.Id> ids =
-        Streams.stream(in).map(a -> new Account.Id(a._accountId)).collect(toSet());
+    Set<Account.Id> ids = Streams.stream(in).map(a -> Account.id(a._accountId)).collect(toSet());
     Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
     for (AccountInfo info : in) {
-      Account.Id id = new Account.Id(info._accountId);
+      Account.Id id = Account.id(info._accountId);
       AccountState state = accountStates.get(id);
       if (state != null) {
         if (!options.contains(FillOptions.SECONDARY_EMAILS)
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 8b3e1b3..594453b 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -168,7 +168,7 @@
         }
 
         ProjectWatchKey key =
-            ProjectWatchKey.create(new Project.NameKey(projectName), notifyValue.filter());
+            ProjectWatchKey.create(Project.nameKey(projectName), notifyValue.filter());
         if (!projectWatches.containsKey(key)) {
           projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
         }
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 50a5e9f..da091e0 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -221,7 +221,7 @@
           cfg.getSubsections("groups").stream()
               .filter(
                   sub -> {
-                    AccountGroup.UUID uuid = new AccountGroup.UUID(sub);
+                    AccountGroup.UUID uuid = AccountGroup.uuid(sub);
                     GroupBackend groupBackend = universalGroupBackend.backend(uuid);
                     return groupBackend == null || groupBackend.get(uuid) == null;
                   })
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index 5d12ae1..611b44d 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -96,7 +96,7 @@
     private static ExternalId toExternalId(ObjectIdConverter idConverter, ExternalIdProto proto) {
       return ExternalId.create(
           ExternalId.Key.parse(proto.getKey()),
-          new Account.Id(proto.getAccountId()),
+          Account.id(proto.getAccountId()),
           // ExternalId treats null and empty strings the same, so no need to distinguish here.
           proto.getEmail(),
           proto.getPassword(),
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index c363b5b..6583a7e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -27,6 +27,7 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
@@ -38,7 +39,6 @@
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
@@ -361,7 +361,7 @@
 
     return create(
         externalIdKey,
-        new Account.Id(accountId),
+        Account.id(accountId),
         Strings.emptyToNull(email),
         Strings.emptyToNull(password),
         blobId);
@@ -428,10 +428,10 @@
 
   public byte[] toByteArray() {
     checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
-    byte[] b = new byte[2 * Constants.OBJECT_ID_STRING_LENGTH + 1];
+    byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
     key().sha1().copyTo(b, 0);
-    b[Constants.OBJECT_ID_STRING_LENGTH] = ':';
-    blobId().copyTo(b, Constants.OBJECT_ID_STRING_LENGTH + 1);
+    b[ObjectIds.STR_LEN] = ':';
+    blobId().copyTo(b, ObjectIds.STR_LEN + 1);
     return b;
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 41d02f5..f4ff471 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -670,7 +671,7 @@
 
   @Override
   public RevCommit commit(MetaDataUpdate update) throws IOException {
-    oldRev = revision != null ? revision.copy() : ObjectId.zeroId();
+    oldRev = ObjectIds.copyOrZero(revision);
     RevCommit commit = super.commit(update);
     updateCount.increment();
     return commit;
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index acff137..d1a011d 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -92,7 +92,7 @@
   public ChangeApi create(ChangeInput in) throws RestApiException {
     try {
       ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
-      return api.create(changes.parse(new Change.Id(out._number)));
+      return api.create(changes.parse(Change.id(out._number)));
     } catch (Exception e) {
       throw asRestApiException("Cannot create change", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 2df7ae6..27073db 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -476,7 +476,7 @@
       // Reread change to pick up new notes refs.
       return changes
           .id(revision.getChange().getId().get())
-          .revision(revision.getPatchSet().getId().get())
+          .revision(revision.getPatchSet().id().get())
           .draft(id);
     } catch (Exception e) {
       throw asRestApiException("Cannot create draft", e);
@@ -587,20 +587,20 @@
         ListMultimapBuilder.treeKeys().arrayListValues().build();
     try {
       Iterable<PatchSetApproval> approvals =
-          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().getId(), null, null);
+          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id(), null, null);
       AccountLoader accountLoader =
           accountLoaderFactory.create(
               EnumSet.of(
                   FillOptions.ID, FillOptions.NAME, FillOptions.EMAIL, FillOptions.USERNAME));
       for (PatchSetApproval approval : approvals) {
-        String label = approval.getLabel();
+        String label = approval.label();
         ApprovalInfo info =
             new ApprovalInfo(
-                approval.getAccountId().get(),
-                Integer.valueOf(approval.getValue()),
+                approval.accountId().get(),
+                Integer.valueOf(approval.value()),
                 null,
-                approval.getTag(),
-                approval.getGranted());
+                approval.tag().orElse(null),
+                approval.granted());
         accountLoader.put(info);
         result.get(label).add(info);
       }
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index 46a22c0..34864f9 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -45,7 +45,7 @@
   @Override
   public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(n));
+    Optional<InternalGroup> group = groupCache.get(AccountGroup.nameKey(n));
     if (!group.isPresent()) {
       throw new CmdLineException(owner, localizable("Group \"%s\" does not exist"), n);
     }
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 2dd0c7a..628dbbf 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -53,7 +53,7 @@
   @Override
   public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    AccountGroup.UUID uuid = new AccountGroup.UUID(n);
+    AccountGroup.UUID uuid = AccountGroup.uuid(n);
     if (groupBackend.handles(uuid)) {
       GroupDescription.Basic d = groupBackend.get(uuid);
       if (d != null) {
diff --git a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
index 77f0fd8..a91883d 100644
--- a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
@@ -18,7 +18,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -59,8 +59,8 @@
 
     try {
       final Change.Key key = Change.Key.parse(tokens.get(2));
-      final Project.NameKey project = new Project.NameKey(tokens.get(0));
-      final Branch.NameKey branch = new Branch.NameKey(project, tokens.get(1));
+      final Project.NameKey project = Project.nameKey(tokens.get(0));
+      final BranchNameKey branch = BranchNameKey.create(project, tokens.get(1));
       for (ChangeData cd : queryProvider.get().byBranchKey(branch, key)) {
         setter.addValue(cd.getId());
         return 1;
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index f33a4ed..a4af62d 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -72,7 +72,7 @@
     }
 
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
-    Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
+    Project.NameKey nameKey = Project.nameKey(nameWithoutSuffix);
 
     ProjectState state;
     try {
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/server/auth/ldap/Helper.java
index 888a554..b9c6d8e 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -301,7 +301,7 @@
 
     final Set<AccountGroup.UUID> actual = new HashSet<>();
     for (String dn : groupDNs) {
-      actual.add(new AccountGroup.UUID(LDAP_UUID + dn));
+      actual.add(AccountGroup.uuid(LDAP_UUID + dn));
     }
 
     if (actual.isEmpty()) {
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 87a4abf..2433f67 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -92,7 +92,7 @@
   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));
+        AccountGroup.uuid(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
   }
 
   private static String cnFor(String dn) {
@@ -164,7 +164,7 @@
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(name);
+    AccountGroup.UUID uuid = AccountGroup.uuid(name);
     if (isLdapUUID(uuid)) {
       GroupDescription.Basic g = get(uuid);
       if (g == null) {
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 0980116..4d7d70e 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Converter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -27,7 +28,7 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.cache.serialize.IntKeyCacheSerializer;
+import com.google.gerrit.server.cache.serialize.IntegerCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -45,7 +46,9 @@
       protected void configure() {
         persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class)
             .version(1)
-            .keySerializer(new IntKeyCacheSerializer<>(Account.Id::new))
+            .keySerializer(
+                CacheSerializer.convert(
+                    IntegerCacheSerializer.INSTANCE, Converter.from(Account.Id::get, Account::id)))
             .valueSerializer(new Serializer());
       }
     };
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
index 9600074..a3a2054 100644
--- a/java/com/google/gerrit/server/cache/serialize/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -4,8 +4,8 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/proto",
-        "//java/com/google/gwtorm",
         "//lib:guava",
         "//lib:protobuf",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 2d41f2c..5377fc1 100644
--- a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.cache.serialize;
 
+import com.google.common.base.Converter;
+
 /**
  * Interface for serializing/deserializing a type to/from a persistent cache.
  *
@@ -22,6 +24,27 @@
  */
 public interface CacheSerializer<T> {
   /**
+   * Convert a serializer of one type to another type using a {@link Converter}.
+   *
+   * @param delegate underlying serializer.
+   * @param converter converter between an arbitrary type {@code T} and {@code delegate}'s type.
+   * @return serializer of type {@code T}.
+   */
+  static <T, D> CacheSerializer<T> convert(CacheSerializer<D> delegate, Converter<T, D> converter) {
+    return new CacheSerializer<T>() {
+      @Override
+      public byte[] serialize(T object) {
+        return delegate.serialize(converter.convert(object));
+      }
+
+      @Override
+      public T deserialize(byte[] in) {
+        return converter.reverse().convert(delegate.deserialize(in));
+      }
+    };
+  }
+
+  /**
    * Serializes the object to a new byte array.
    *
    * @param object object to serialize.
diff --git a/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
deleted file mode 100644
index 85530f4..0000000
--- a/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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.serialize;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gwtorm.client.IntKey;
-import java.util.function.Function;
-
-public class IntKeyCacheSerializer<K extends IntKey<?>> implements CacheSerializer<K> {
-  private final Function<Integer, K> factory;
-
-  public IntKeyCacheSerializer(Function<Integer, K> factory) {
-    this.factory = requireNonNull(factory);
-  }
-
-  @Override
-  public byte[] serialize(K object) {
-    return IntegerCacheSerializer.INSTANCE.serialize(object.get());
-  }
-
-  @Override
-  public K deserialize(byte[] in) {
-    return factory.apply(IntegerCacheSerializer.INSTANCE.deserialize(in));
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
index 500875d..7c0f84f 100644
--- a/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializer.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.cache.serialize;
 
-import org.eclipse.jgit.lib.Constants;
+import com.google.gerrit.git.ObjectIds;
 import org.eclipse.jgit.lib.ObjectId;
 
 public enum ObjectIdCacheSerializer implements CacheSerializer<ObjectId> {
@@ -22,14 +22,14 @@
 
   @Override
   public byte[] serialize(ObjectId object) {
-    byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+    byte[] buf = new byte[ObjectIds.LEN];
     object.copyRawTo(buf, 0);
     return buf;
   }
 
   @Override
   public ObjectId deserialize(byte[] in) {
-    if (in == null || in.length != Constants.OBJECT_ID_LENGTH) {
+    if (in == null || in.length != ObjectIds.LEN) {
       throw new IllegalArgumentException("Failed to deserialize ObjectId");
     }
     return ObjectId.fromRaw(in);
diff --git a/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
index eb946a9..22654e5 100644
--- a/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
+++ b/java/com/google/gerrit/server/cache/serialize/ObjectIdConverter.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
 
+import com.google.gerrit.git.ObjectIds;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -35,7 +35,7 @@
     return new ObjectIdConverter();
   }
 
-  private final byte[] buf = new byte[OBJECT_ID_LENGTH];
+  private final byte[] buf = new byte[ObjectIds.LEN];
 
   private ObjectIdConverter() {}
 
@@ -46,10 +46,7 @@
 
   public ObjectId fromByteString(ByteString in) {
     checkArgument(
-        in.size() == OBJECT_ID_LENGTH,
-        "expected ByteString of length %s: %s",
-        OBJECT_ID_LENGTH,
-        in);
+        in.size() == ObjectIds.LEN, "expected ByteString of length %s: %s", ObjectIds.LEN, in);
     in.copyTo(buf, 0);
     return ObjectId.fromRaw(buf);
   }
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index 610290d..a8ebcb2 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -240,7 +240,7 @@
       addReviewersEmail.emailReviewers(
           ctx.getUser().asIdentifiedUser(),
           change,
-          Lists.transform(addedReviewers, PatchSetApproval::getAccountId),
+          Lists.transform(addedReviewers, PatchSetApproval::accountId),
           addedCCs,
           addedReviewersByEmail,
           addedCCsByEmail,
@@ -249,7 +249,7 @@
     if (!addedReviewers.isEmpty()) {
       List<AccountState> reviewers =
           addedReviewers.stream()
-              .map(r -> accountCache.get(r.getAccountId()))
+              .map(r -> accountCache.get(r.accountId()))
               .flatMap(Streams::stream)
               .collect(toList());
       reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index 4ad3c67..0535a4e 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
@@ -30,7 +31,6 @@
 import com.google.gerrit.metrics.MetricMaker;
 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.server.cache.CacheModule;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -160,7 +160,7 @@
       Integer n = Ints.tryParse(id);
       if (n != null) {
         checkIdType(ChangeIdType.NUMERIC_ID, enforceDeprecation, n.toString());
-        return find(new Change.Id(n));
+        return find(Change.id(n));
       }
     }
 
@@ -169,7 +169,7 @@
     InternalChangeQuery query = queryProvider.get().noFields();
 
     // Try commit hash
-    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
+    if (id.matches("^([0-9a-fA-F]{" + ObjectIds.ABBREV_STR_LEN + "," + ObjectIds.STR_LEN + "})$")) {
       checkIdType(ChangeIdType.COMMIT_HASH, enforceDeprecation, id);
       return asChangeNotes(query.byCommit(id));
     }
@@ -193,7 +193,7 @@
   }
 
   private List<ChangeNotes> fromProjectNumber(String project, int changeNumber) {
-    Change.Id cId = new Change.Id(changeNumber);
+    Change.Id cId = Change.id(changeNumber);
     try {
       return ImmutableList.of(
           changeNotesFactory.createChecked(Project.NameKey.parse(project), cId));
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 9f9ec1f..6e8f04f 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -36,7 +36,7 @@
 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.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -168,7 +168,7 @@
     this.reviewerAdder = reviewerAdder;
 
     this.changeId = changeId;
-    this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
+    this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
     this.commitId = commitId.copy();
     this.refName = refName;
     this.reviewerInputs = ImmutableList.of();
@@ -185,7 +185,7 @@
             getChangeKey(ctx.getRevWalk(), commitId),
             changeId,
             ctx.getAccountId(),
-            new Branch.NameKey(ctx.getProject(), refName),
+            BranchNameKey.create(ctx.getProject(), refName),
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
@@ -201,7 +201,7 @@
     rw.parseBody(commit);
     List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
     if (!idList.isEmpty()) {
-      return new Change.Key(idList.get(idList.size() - 1).trim());
+      return Change.key(idList.get(idList.size() - 1).trim());
     }
     ObjectId changeId =
         ChangeIdUtil.computeChangeId(
@@ -212,7 +212,7 @@
             commit.getShortMessage());
     StringBuilder changeIdStr = new StringBuilder();
     changeIdStr.append("I").append(ObjectId.toString(changeId));
-    return new Change.Key(changeIdStr.toString());
+    return Change.key(changeIdStr.toString());
   }
 
   public PatchSet.Id getPatchSetId() {
@@ -377,7 +377,7 @@
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setChangeId(change.getKey().get());
     update.setSubjectForCommit("Create change");
-    update.setBranch(change.getDest().get());
+    update.setBranch(change.getDest().branch());
     update.setTopic(change.getTopic());
     update.setPsDescription(patchSetDescription);
     update.setPrivate(isPrivate);
@@ -426,9 +426,9 @@
     if (message != null) {
       changeMessage =
           ChangeMessagesUtil.newMessage(
-              patchSet.getId(),
+              patchSet.id(),
               ctx.getUser(),
-              patchSet.getCreatedOn(),
+              patchSet.createdOn(),
               message,
               ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
       cmUtil.addChangeMessage(update, changeMessage);
@@ -453,7 +453,7 @@
                 cm.setNotify(notify);
                 cm.addReviewers(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::getAccountId)
+                        .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
                 cm.addReviewersByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
@@ -518,14 +518,14 @@
           new CommitReceivedEvent(
               cmd,
               projectState.getProject(),
-              change.getDest().get(),
+              change.getDest().branch(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
               ctx.getIdentifiedUser())) {
         commitValidatorsFactory
             .forGerritCommits(
                 permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
-                new Branch.NameKey(ctx.getProject(), refName),
+                BranchNameKey.create(ctx.getProject(), refName),
                 ctx.getIdentifiedUser(),
                 new NoSshInfo(),
                 ctx.getRevWalk(),
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 9e43cee..d4b347b 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -286,7 +286,7 @@
 
   public ChangeInfo format(RevisionResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true, ChangeInfo::new);
+    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, ChangeInfo::new);
   }
 
   public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
@@ -297,7 +297,7 @@
       Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
       for (QueryResult<ChangeData> r : in) {
         List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
-        infos.forEach(c -> cache.put(new Change.Id(c._number), c));
+        infos.forEach(c -> cache.put(Change.id(c._number), c));
         if (!infos.isEmpty() && r.more()) {
           infos.get(infos.size() - 1)._moreChanges = true;
         }
@@ -451,7 +451,7 @@
     Change c = result.change();
     if (c != null) {
       info.project = c.getProject().get();
-      info.branch = c.getDest().getShortName();
+      info.branch = c.getDest().shortName();
       info.topic = c.getTopic();
       info.changeId = c.getKey().get();
       info.subject = c.getSubject();
@@ -499,7 +499,7 @@
 
     Change in = cd.change();
     out.project = in.getProject().get();
-    out.branch = in.getDest().getShortName();
+    out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
     out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
@@ -656,8 +656,8 @@
     if (!s.isPresent()) {
       return;
     }
-    out.submitted = s.get().getGranted();
-    out.submitter = accountLoader.get(s.get().getAccountId());
+    out.submitted = s.get().granted();
+    out.submitter = accountLoader.get(s.get().accountId());
   }
 
   private Collection<ChangeMessageInfo> messages(ChangeData cd) {
@@ -699,7 +699,7 @@
         continue;
       }
       for (ApprovalInfo ai : label.all) {
-        Account.Id id = new Account.Id(ai._accountId);
+        Account.Id id = Account.id(ai._accountId);
 
         if (canRemoveAnyReviewer
             || removeReviewerControl.testRemoveReviewer(
@@ -719,7 +719,7 @@
     if (ccs != null) {
       for (AccountInfo ai : ccs) {
         if (ai._accountId != null) {
-          Account.Id id = new Account.Id(ai._accountId);
+          Account.Id id = Account.id(ai._accountId);
           if (canRemoveAnyReviewer
               || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
             removable.add(id);
@@ -784,7 +784,7 @@
     }
     Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
     for (PatchSet patchSet : src) {
-      map.put(patchSet.getId(), patchSet);
+      map.put(patchSet.id(), patchSet);
     }
     return map;
   }
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
new file mode 100644
index 0000000..09380ad
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Change;
+import com.google.gerrit.reviewdb.client.Change.Key;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import java.lang.reflect.Type;
+
+/**
+ * Adapter that serializes {@link com.google.gerrit.reviewdb.client.Change.Key}'s {@code key} field
+ * as {@code id}, for backwards compatibility in stream-events.
+ */
+// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
+// AutoValue method.
+public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
+  @Override
+  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
+    JsonObject obj = new JsonObject();
+    obj.addProperty("id", src.get());
+    return obj;
+  }
+
+  @Override
+  public Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    JsonElement keyJson = json.getAsJsonObject().get("id");
+    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
+      throw new JsonParseException("Key is not a string: " + keyJson);
+    }
+    String key = keyJson.getAsJsonPrimitive().getAsString();
+    return Change.key(key);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 4c68f2d..45fc8b1 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -367,13 +367,13 @@
     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) {
+    if (patch.id().get() > 1) {
       try {
         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.id().get() < patch.id().get()
+              && (ps.id().get() > priorPs.id().get() || priorPs == patch)) {
             // We only want the previous patch set, so walk until the last one
             priorPs = ps;
           }
@@ -386,17 +386,13 @@
         if (priorPs != patch) {
           kind =
               cache.getChangeKind(
-                  change.project(),
-                  rw,
-                  repoConfig,
-                  ObjectId.fromString(priorPs.getRevision().get()),
-                  ObjectId.fromString(patch.getRevision().get()));
+                  change.project(), rw, repoConfig, priorPs.commitId(), patch.commitId());
         }
       } catch (StorageException e) {
         // Do nothing; assume we have a complex change
         logger.atWarning().withCause(e).log(
             "Unable to get change kind for patchSet %s of change %s",
-            patch.getPatchSetId(), change.getId());
+            patch.number(), change.getId());
       }
     }
     return kind;
@@ -412,7 +408,7 @@
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to open
     // the repository.
-    if (patch.getId().get() > 1) {
+    if (patch.id().get() > 1) {
       try (Repository repo = repoManager.openRepository(change.getProject());
           RevWalk rw = new RevWalk(repo)) {
         kind =
@@ -422,7 +418,7 @@
         // Do nothing; assume we have a complex change
         logger.atWarning().withCause(e).log(
             "Unable to get change kind for patchSet %s of change %s",
-            patch.getPatchSetId(), change.getChangeId());
+            patch.number(), change.getChangeId());
       }
     }
     return kind;
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 98b728f..19a4e5c 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -149,7 +149,7 @@
       accounts.add(getChange().getAssignee());
     }
     try {
-      patchSetUtil.byChange(notes).stream().map(PatchSet::getUploader).forEach(accounts::add);
+      patchSetUtil.byChange(notes).stream().map(PatchSet::uploader).forEach(accounts::add);
 
       // It's intentional to include the states for *all* reviewers into the ETag computation.
       // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
diff --git a/java/com/google/gerrit/server/change/ChangeTriplet.java b/java/com/google/gerrit/server/change/ChangeTriplet.java
index 2daeb7c..f8b11b1 100644
--- a/java/com/google/gerrit/server/change/ChangeTriplet.java
+++ b/java/com/google/gerrit/server/change/ChangeTriplet.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.Optional;
@@ -27,8 +27,8 @@
     return format(change.getDest(), change.getKey());
   }
 
-  private static String format(Branch.NameKey branch, Change.Key change) {
-    return branch.getParentKey().get() + "~" + branch.getShortName() + "~" + change.get();
+  private static String format(BranchNameKey branch, Change.Key change) {
+    return branch.project().get() + "~" + branch.shortName() + "~" + change.get();
   }
 
   /**
@@ -53,14 +53,14 @@
     String changeId = Url.decode(triplet.substring(z + 1));
     return Optional.of(
         new AutoValue_ChangeTriplet(
-            new Branch.NameKey(new Project.NameKey(project), branch), new Change.Key(changeId)));
+            BranchNameKey.create(Project.nameKey(project), branch), Change.key(changeId)));
   }
 
   public final Project.NameKey project() {
-    return branch().getParentKey();
+    return branch().project();
   }
 
-  public abstract Branch.NameKey branch();
+  public abstract BranchNameKey branch();
 
   public abstract Change.Key id();
 
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 80b7190..0e555e9 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -238,7 +238,7 @@
   }
 
   private boolean openRepo() {
-    Project.NameKey project = change().getDest().getParentKey();
+    Project.NameKey project = change().getDest().project();
     try {
       repo = repoManager.openRepository(project);
       oi = repo.newObjectInserter();
@@ -265,7 +265,7 @@
     try {
       refs =
           repo.getRefDatabase()
-              .exactRef(all.stream().map(ps -> ps.getId().toRefName()).toArray(String[]::new));
+              .exactRef(all.stream().map(ps -> ps.id().toRefName()).toArray(String[]::new));
     } catch (IOException e) {
       error("error reading refs", e);
       refs = Collections.emptyMap();
@@ -274,12 +274,9 @@
     List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
     for (PatchSet ps : all) {
       // Check revision format.
-      int psNum = ps.getId().get();
-      String refName = ps.getId().toRefName();
-      ObjectId objId = parseObjectId(ps.getRevision().get(), "patch set " + psNum);
-      if (objId == null) {
-        continue;
-      }
+      int psNum = ps.id().get();
+      String refName = ps.id().toRefName();
+      ObjectId objId = ps.commitId();
       patchSetsBySha.put(objId, ps);
 
       // Check ref existence.
@@ -299,13 +296,13 @@
       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.id()));
         }
         continue;
       } else if (refProblem != null && fix != null) {
         fixPatchSetRef(refProblem, ps);
       }
-      if (ps.getId().equals(change().currentPatchSetId())) {
+      if (ps.id().equals(change().currentPatchSetId())) {
         currPsCommit = psCommit;
       }
     }
@@ -319,7 +316,7 @@
         problem(
             String.format(
                 "Multiple patch sets pointing to %s: %s",
-                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
+                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::number)));
       }
     }
 
@@ -327,7 +324,7 @@
   }
 
   private void checkMerged() {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     Ref dest;
     try {
       dest = repo.getRefDatabase().exactRef(refName);
@@ -351,15 +348,15 @@
       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.id().get() + " is merged");
         return;
       }
-      checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged);
+      checkMergedBitMatchesStatus(currPs.id(), currPsCommit, merged);
     }
   }
 
   private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     return problem(
         formatProblemMessage(
             "Patch set %d (%s) is merged into destination ref %s (%s), but change"
@@ -368,7 +365,7 @@
   }
 
   private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
-    String refName = change().getDest().get();
+    String refName = change().getDest().branch();
     if (merged && !change().isMerged()) {
       ProblemInfo p = wrongChangeStatus(psId, commit);
       if (fix != null) {
@@ -379,7 +376,7 @@
           formatProblemMessage(
               "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()));
+              currPs.id().get(), commit.name(), refName, tip.name()));
     }
   }
 
@@ -395,7 +392,11 @@
   }
 
   private void checkExpectMergedAs() {
-    ObjectId objId = parseObjectId(fix.expectMergedAs, "expected merged commit");
+    if (!ObjectId.isId(fix.expectMergedAs)) {
+      problem("Invalid revision on expected merged commit: " + fix.expectMergedAs);
+      return;
+    }
+    ObjectId objId = ObjectId.fromString(fix.expectMergedAs);
     RevCommit commit = parseCommit(objId, "expected merged commit");
     if (commit == null) {
       return;
@@ -406,7 +407,7 @@
         problem(
             String.format(
                 "Expected merged commit %s is not merged into destination ref %s (%s)",
-                commit.name(), change().getDest().get(), tip.name()));
+                commit.name(), change().getDest().branch(), tip.name()));
         return;
       }
 
@@ -420,8 +421,7 @@
           continue;
         }
         try {
-          Change c =
-              notesFactory.createChecked(change().getProject(), psId.getParentKey()).getChange();
+          Change c = notesFactory.createChecked(change().getProject(), psId.changeId()).getChange();
           if (!c.getDest().equals(change().getDest())) {
             continue;
           }
@@ -601,9 +601,9 @@
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
     try {
-      RefUpdate ru = repo.updateRef(ps.getId().toRefName());
+      RefUpdate ru = repo.updateRef(ps.id().toRefName());
       ru.setForceUpdate(true);
-      ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
+      ru.setNewObjectId(ps.commitId());
       ru.setRefLogIdent(newRefLogIdent());
       ru.setRefLogMessage("Repair patch set ref", true);
       RefUpdate.Result result = ru.update();
@@ -630,7 +630,7 @@
       }
     } catch (IOException e) {
       String msg = "Error fixing patch set ref";
-      logger.atWarning().withCause(e).log("%s %s", msg, ps.getId().toRefName());
+      logger.atWarning().withCause(e).log("%s %s", msg, ps.id().toRefName());
       p.status = Status.FIX_FAILED;
       p.outcome = msg;
     }
@@ -640,7 +640,7 @@
     try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       for (DeletePatchSetFromDbOp op : ops) {
-        checkArgument(op.psId.getParentKey().equals(notes.getChangeId()));
+        checkArgument(op.psId.changeId().equals(notes.getChangeId()));
         bu.addOp(notes.getChangeId(), op);
       }
       bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
@@ -652,7 +652,7 @@
       }
     } catch (UpdateException | RestApiException e) {
       String msg = "Error deleting patch set";
-      logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.getParentKey());
+      logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.changeId());
       for (DeletePatchSetFromDbOp op : ops) {
         // Overwrite existing statuses that were set before the transaction was
         // rolled back.
@@ -714,8 +714,8 @@
       // and whether they are seen by this op; we are already given the full set
       // of patch sets that will eventually be deleted in this update.
       for (PatchSet ps : psUtil.byChange(ctx.getNotes())) {
-        if (!toDelete.contains(ps.getId())) {
-          all.add(ps.getId());
+        if (!toDelete.contains(ps.id())) {
+          all.add(ps.id());
         }
       }
       if (all.isEmpty()) {
@@ -734,15 +734,6 @@
     return serverIdent.get();
   }
 
-  private ObjectId parseObjectId(String objIdStr, String desc) {
-    try {
-      return ObjectId.fromString(objIdStr);
-    } catch (IllegalArgumentException e) {
-      problem(String.format("Invalid revision on %s: %s", desc, objIdStr));
-      return null;
-    }
-  }
-
   private RevCommit parseCommit(ObjectId objId, String desc) {
     try {
       return rw.parseCommit(objId);
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 1324b7f..a56404d 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
@@ -71,7 +70,7 @@
     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);
+    cleanUpReferences(id, patchSets);
 
     ctx.deleteChange();
     changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
@@ -87,37 +86,34 @@
       if (isPatchSetMerged(ctx, patchSet)) {
         throw new ResourceConflictException(
             String.format(
-                "Cannot delete change %s: patch set %s is already merged",
-                id, patchSet.getPatchSetId()));
+                "Cannot delete change %s: patch set %s is already merged", id, patchSet.number()));
       }
     }
   }
 
   private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
-    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
+    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().branch());
     if (!destId.isPresent()) {
       return false;
     }
 
     RevWalk revWalk = ctx.getRevWalk();
-    ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
-    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
+    return revWalk.isMergedInto(
+        revWalk.parseCommit(patchSet.commitId()), revWalk.parseCommit(destId.get()));
   }
 
-  private void cleanUpReferences(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws NoSuchChangeException {
+  private void cleanUpReferences(Change.Id id, Collection<PatchSet> patchSets) throws IOException {
     for (PatchSet ps : patchSets) {
-      accountPatchReviewStore.run(s -> s.clearReviewed(ps.getId()));
+      accountPatchReviewStore.run(s -> s.clearReviewed(ps.id()));
     }
 
-    // Non-atomic operation on Accounts table; not much we can do to make it
-    // atomic.
-    starredChangesUtil.unstarAll(ctx.getChange().getProject(), id);
+    // Non-atomic operation on All-Users refs; not much we can do to make it atomic.
+    starredChangesUtil.unstarAllForChangeDeletion(id);
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws IOException {
-    String prefix = new PatchSet.Id(id, 1).toRefName();
+    String prefix = PatchSet.id(id, 1).toRefName();
     prefix = prefix.substring(0, prefix.length() - 1);
     for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
       ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 4b5572b..b42e192 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -55,7 +55,7 @@
     String msg = "Removed reviewer " + reviewer;
     changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(change.getId(), ChangeUtil.messageUuid()),
+            ChangeMessage.key(change.getId(), ChangeUtil.messageUuid()),
             ctx.getAccountId(),
             ctx.getWhen(),
             psId);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 29458a8..8bd69e1 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -134,14 +134,14 @@
       // Check if removing this vote is OK
       removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
       del.add(a);
-      if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-        oldApprovals.put(a.getLabel(), a.getValue());
+      if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
+        oldApprovals.put(a.label(), a.value());
         removedVotesMsg
             .append("* ")
-            .append(a.getLabel())
-            .append(formatLabelValue(a.getValue()))
+            .append(a.label())
+            .append(formatLabelValue(a.value()))
             .append(" by ")
-            .append(userFactory.create(a.getAccountId()).getNameEmail())
+            .append(userFactory.create(a.accountId()).getNameEmail())
             .append("\n");
         votesRemoved = true;
       }
@@ -152,7 +152,7 @@
     } else {
       msg.append(".");
     }
-    ChangeUpdate update = ctx.getUpdate(currPs.getId());
+    ChangeUpdate update = ctx.getUpdate(currPs.id());
     update.removeReviewer(reviewerId);
 
     changeMessage =
@@ -195,7 +195,7 @@
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
     approvals = approvalsUtil.byChange(ctx.getNotes()).values();
-    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
   private String formatLabelValue(short value) {
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 8353501..c6bcd81 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -129,7 +129,7 @@
       cm.setNotify(notify);
       cm.send();
     } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.getId());
+      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
     } finally {
       requestContext.setContext(old);
     }
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index 56cc8df..8e7f8ea 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -21,7 +21,6 @@
 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.RevId;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -44,27 +43,20 @@
 
   public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException {
-    return toFileInfoMap(change, patchSet.getRevision(), null);
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
-      throws PatchListNotAvailableException {
-    ObjectId objectId = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, objectId, base);
+    return toFileInfoMap(change, patchSet.commitId(), null);
   }
 
   public Map<String, FileInfo> toFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
       throws PatchListNotAvailableException {
-    ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
+    ObjectId a = base != null ? base.commitId() : null;
     return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
   }
 
-  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
+  public Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, int parent)
       throws PatchListNotAvailableException {
-    ObjectId b = ObjectId.fromString(revision.get());
     return toFileInfoMap(
-        change, PatchListKey.againstParentNum(parent + 1, b, Whitespace.IGNORE_NONE));
+        change, PatchListKey.againstParentNum(parent + 1, objectId, Whitespace.IGNORE_NONE));
   }
 
   private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
diff --git a/java/com/google/gerrit/server/change/FileResource.java b/java/com/google/gerrit/server/change/FileResource.java
index bd7557f..ba724ec 100644
--- a/java/com/google/gerrit/server/change/FileResource.java
+++ b/java/com/google/gerrit/server/change/FileResource.java
@@ -29,7 +29,7 @@
 
   public FileResource(RevisionResource rev, String name) {
     this.rev = rev;
-    this.key = new Patch.Key(rev.getPatchSet().getId(), name);
+    this.key = Patch.key(rev.getPatchSet().id(), name);
   }
 
   public Patch.Key getPatchKey() {
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 1ec1717..2a48c3b 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -88,24 +88,23 @@
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
     LabelTypes labelTypes = projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes);
     for (PatchSetApproval psa : approvals) {
-      Change.Id changeId = psa.getKey().getParentKey().getParentKey();
+      Change.Id changeId = psa.key().patchSetId().changeId();
       checkArgument(
           changeId.equals(notes.getChangeId()),
           "Approval %s does not match change %s",
-          psa.getKey(),
+          psa.key(),
           notes.getChange().getKey());
       if (psa.isLegacySubmit()) {
         unchanged.add(psa);
         continue;
       }
-      LabelType label = labelTypes.byLabel(psa.getLabelId());
+      LabelType label = labelTypes.byLabel(psa.labelId());
       if (label == null) {
         deleted.add(psa);
         continue;
       }
-      PatchSetApproval copy = copy(psa);
-      applyTypeFloor(label, copy);
-      if (copy.getValue() != psa.getValue()) {
+      PatchSetApproval copy = applyTypeFloor(label, psa);
+      if (copy.value() != psa.value()) {
         updated.add(copy);
       } else {
         unchanged.add(psa);
@@ -114,18 +113,16 @@
     return Result.create(unchanged, updated, deleted);
   }
 
-  private PatchSetApproval copy(PatchSetApproval src) {
-    return new PatchSetApproval(src.getPatchSetId(), src);
-  }
-
-  private void applyTypeFloor(LabelType lt, PatchSetApproval a) {
+  private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
+    PatchSetApproval.Builder b = a.toBuilder();
     LabelValue atMin = lt.getMin();
-    if (atMin != null && a.getValue() < atMin.getValue()) {
-      a.setValue(atMin.getValue());
+    if (atMin != null && a.value() < atMin.getValue()) {
+      b.value(atMin.getValue());
     }
     LabelValue atMax = lt.getMax();
-    if (atMax != null && a.getValue() > atMax.getValue()) {
-      a.setValue(atMax.getValue());
+    if (atMax != null && a.value() > atMax.getValue()) {
+      b.value(atMax.getValue());
     }
+    return b.build();
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 6fde5a5..dd9e08b 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -206,8 +206,8 @@
       if (standard) {
         for (PatchSetApproval psa : cd.currentApprovals()) {
           if (type.matches(psa)) {
-            short val = psa.getValue();
-            Account.Id accountId = psa.getAccountId();
+            short val = psa.value();
+            Account.Id accountId = psa.accountId();
             setLabelScores(accountLoader, type, e.getValue(), val, accountId);
           }
         }
@@ -260,7 +260,7 @@
             accountId,
             null,
             null)) {
-      result.put(psa.getLabel(), psa.getValue());
+      result.put(psa.label(), psa.value());
     }
     return result;
   }
@@ -279,7 +279,7 @@
       // we aren't including 0 votes for all users below, so we can just look at
       // the latest patch set (in the next loop).
       for (PatchSetApproval psa : cd.approvals().values()) {
-        allUsers.add(psa.getAccountId());
+        allUsers.add(psa.accountId());
       }
     }
 
@@ -287,13 +287,13 @@
     SetMultimap<Account.Id, PatchSetApproval> current =
         MultimapBuilder.hashKeys().hashSetValues().build();
     for (PatchSetApproval a : cd.currentApprovals()) {
-      allUsers.add(a.getAccountId());
-      LabelType type = labelTypes.byLabel(a.getLabelId());
+      allUsers.add(a.accountId());
+      LabelType type = labelTypes.byLabel(a.labelId());
       if (type != null) {
         labelNames.add(type.getName());
         // Not worth the effort to distinguish between votable/non-votable for 0
         // values on closed changes, since they can't vote anyway.
-        current.put(a.getAccountId(), a);
+        current.put(a.accountId(), a);
       }
     }
 
@@ -335,19 +335,19 @@
         }
       }
       for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.getLabelId());
+        LabelType type = labelTypes.byLabel(psa.labelId());
         if (type == null) {
           continue;
         }
 
-        short val = psa.getValue();
+        short val = psa.value();
         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.date = psa.granted();
+          info.tag = psa.tag().orElse(null);
+          if (psa.postSubmit()) {
             info.postSubmit = true;
           }
         }
@@ -441,13 +441,13 @@
     Set<Account.Id> allUsers = new HashSet<>();
     allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
     for (PatchSetApproval psa : cd.approvals().values()) {
-      allUsers.add(psa.getAccountId());
+      allUsers.add(psa.accountId());
     }
 
     Table<Account.Id, String, PatchSetApproval> current =
         HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
     for (PatchSetApproval psa : cd.currentApprovals()) {
-      current.put(psa.getAccountId(), psa.getLabel(), psa);
+      current.put(psa.accountId(), psa.label(), psa);
     }
 
     LabelTypes labelTypes = cd.getLabelTypes();
@@ -467,16 +467,16 @@
         Timestamp date = null;
         PatchSetApproval psa = current.get(accountId, lt.getName());
         if (psa != null) {
-          value = Integer.valueOf(psa.getValue());
+          value = Integer.valueOf(psa.value());
           if (value == 0) {
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
             value = perm.test(new LabelPermission(lt)) ? 0 : null;
           }
-          tag = psa.getTag();
-          date = psa.getGranted();
-          if (psa.isPostSubmit()) {
+          tag = psa.tag().orElse(null);
+          date = psa.granted();
+          if (psa.postSubmit()) {
             logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
           }
         } else {
diff --git a/java/com/google/gerrit/server/change/MergeabilityCache.java b/java/com/google/gerrit/server/change/MergeabilityCache.java
index 3a7f3ab..944ac89 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCache.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCache.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -29,7 +29,7 @@
         Ref intoRef,
         SubmitType submitType,
         String mergeStrategy,
-        Branch.NameKey dest,
+        BranchNameKey dest,
         Repository repo) {
       throw new UnsupportedOperationException("Mergeability checking disabled");
     }
@@ -46,7 +46,7 @@
       Ref intoRef,
       SubmitType submitType,
       String mergeStrategy,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository repo);
 
   Boolean getIfPresent(ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy);
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index d408519..0903fc9 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -26,7 +26,7 @@
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
 import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
@@ -191,7 +191,7 @@
       Ref intoRef,
       SubmitType submitType,
       String mergeStrategy,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository repo) {
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index d3649f6..fecc099 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -208,7 +208,7 @@
     if (newGroups.isEmpty()) {
       PatchSet prevPs = psUtil.current(ctx.getNotes());
       if (prevPs != null) {
-        newGroups = prevPs.getGroups();
+        newGroups = prevPs.groups();
       }
     }
     patchSet =
@@ -222,7 +222,7 @@
     if (message != null) {
       changeMessage =
           ChangeMessagesUtil.newMessage(
-              patchSet.getId(),
+              patchSet.id(),
               ctx.getUser(),
               ctx.getWhen(),
               message,
@@ -288,7 +288,7 @@
                 commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
             projectCache.checkedGet(origNotes.getProjectName()).getProject(),
-            origNotes.getChange().getDest().get(),
+            origNotes.getChange().getDest().branch(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
             ctx.getIdentifiedUser())) {
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
index 0859634..63146fa 100644
--- a/java/com/google/gerrit/server/change/PureRevert.java
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -54,8 +54,6 @@
     }
 
     return pureRevertCache.isPureRevert(
-        notes.getProjectName(),
-        ObjectId.fromString(notes.getCurrentPatchSet().getRevision().get()),
-        claimedOriginalObjectId);
+        notes.getProjectName(), notes.getCurrentPatchSet().commitId(), claimedOriginalObjectId);
   }
 }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index fccda7c..688d349 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -20,7 +20,6 @@
 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.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -151,10 +150,8 @@
           NoSuchChangeException, PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
-    RevId oldRev = originalPatchSet.getRevision();
-
     RevWalk rw = ctx.getRevWalk();
-    RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
+    RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
     RevCommit baseCommit = rw.parseCommit(baseCommitId);
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
@@ -164,7 +161,7 @@
       rw.parseBody(baseCommit);
       newCommitMessage =
           newMergeUtil()
-              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.getId());
+              .createCommitMessageOnSubmit(original, baseCommit, notes, originalPatchSet.id());
     } else {
       newCommitMessage = original.getFullMessage();
     }
@@ -178,9 +175,7 @@
 
     rebasedPatchSetId =
         ChangeUtil.nextPatchSetIdFromChangeRefs(
-            ctx.getRepoView()
-                .getRefs(originalPatchSet.getId().getParentKey().toRefPrefix())
-                .keySet(),
+            ctx.getRepoView().getRefs(originalPatchSet.id().changeId().toRefPrefix()).keySet(),
             notes.getChange().currentPatchSetId());
     patchSetInserter =
         patchSetInserterFactory
@@ -195,14 +190,14 @@
           "Patch Set "
               + rebasedPatchSetId.get()
               + ": Patch Set "
-              + originalPatchSet.getId().get()
+              + originalPatchSet.id().get()
               + " was rebased");
     }
 
     if (base != null && !base.notes().getChange().isMerged()) {
       if (!base.notes().getChange().isMerged()) {
         // Add to end of relation chain for open base change.
-        patchSetInserter.setGroups(base.patchSet().getGroups());
+        patchSetInserter.setGroups(base.patchSet().groups());
       } else {
         // If the base is merged, start a new relation chain.
         patchSetInserter.setGroups(GroupCollector.getDefaultGroups(rebasedCommit));
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index a4cf5ba..731648c 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -21,10 +21,10 @@
 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.Branch;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -56,7 +56,7 @@
     this.psUtil = psUtil;
   }
 
-  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest, Repository git, RevWalk rw) {
+  public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
       findBaseRevision(patchSet, dest, git, rw);
       return true;
@@ -64,7 +64,7 @@
       return false;
     } catch (StorageException | IOException e) {
       logger.atWarning().withCause(e).log(
-          "Error checking if patch set %s on %s can be rebased", patchSet.getId(), dest);
+          "Error checking if patch set %s on %s can be rebased", patchSet.id(), dest);
       return false;
     }
   }
@@ -87,18 +87,18 @@
     // Try parsing the base as a ref string.
     PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
     if (basePatchSetId != null) {
-      Change.Id baseChangeId = basePatchSetId.getParentKey();
+      Change.Id baseChangeId = basePatchSetId.changeId();
       ChangeNotes baseNotes = notesFor(rsrc, baseChangeId);
       if (baseNotes != null) {
         return Base.create(
-            notesFor(rsrc, basePatchSetId.getParentKey()), psUtil.get(baseNotes, basePatchSetId));
+            notesFor(rsrc, basePatchSetId.changeId()), psUtil.get(baseNotes, basePatchSetId));
       }
     }
 
     // Try parsing base as a change number (assume current patch set).
     Integer baseChangeId = Ints.tryParse(base);
     if (baseChangeId != null) {
-      ChangeNotes baseNotes = notesFor(rsrc, new Change.Id(baseChangeId));
+      ChangeNotes baseNotes = notesFor(rsrc, Change.id(baseChangeId));
       if (baseNotes != null) {
         return Base.create(baseNotes, psUtil.current(baseNotes));
       }
@@ -108,10 +108,10 @@
     Base ret = null;
     for (ChangeData cd : queryProvider.get().byProjectCommit(rsrc.getProject(), base)) {
       for (PatchSet ps : cd.patchSets()) {
-        if (!ps.getRevision().matches(base)) {
+        if (!ObjectIds.matchesAbbreviation(ps.commitId(), base)) {
           continue;
         }
-        if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
+        if (ret == null || ret.patchSet().id().get() < ps.id().get()) {
           ret = Base.create(cd.notes(), ps);
         }
       }
@@ -141,10 +141,10 @@
    * @throws IOException if accessing the repository fails.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, Branch.NameKey destBranch, Repository git, RevWalk rw)
+      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
       throws RestApiException, IOException {
-    String baseRev = null;
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+    ObjectId baseId = null;
+    RevCommit commit = rw.parseCommit(patchSet.commitId());
 
     if (commit.getParentCount() > 1) {
       throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
@@ -153,12 +153,12 @@
           "Cannot rebase a change without any parents (is this the initial commit?).");
     }
 
-    RevId parentRev = new RevId(commit.getParent(0).name());
+    ObjectId parentId = commit.getParent(0);
 
     CHANGES:
-    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentRev.get())) {
+    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentId.name())) {
       for (PatchSet depPatchSet : cd.patchSets()) {
-        if (!depPatchSet.getRevision().equals(parentRev)) {
+        if (!depPatchSet.commitId().equals(parentId)) {
           continue;
         }
         Change depChange = cd.change();
@@ -168,29 +168,29 @@
         }
 
         if (depChange.isNew()) {
-          if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
+          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
-          baseRev = cd.currentPatchSet().getRevision().get();
+          baseId = cd.currentPatchSet().commitId();
         }
         break CHANGES;
       }
     }
 
-    if (baseRev == null) {
+    if (baseId == null) {
       // We are dependent on a merged PatchSet or have no PatchSet
       // dependencies at all.
-      Ref destRef = git.getRefDatabase().exactRef(destBranch.get());
+      Ref destRef = git.getRefDatabase().exactRef(destBranch.branch());
       if (destRef == null) {
         throw new UnprocessableEntityException(
-            "The destination branch does not exist: " + destBranch.get());
+            "The destination branch does not exist: " + destBranch.branch());
       }
-      baseRev = destRef.getObjectId().getName();
-      if (baseRev.equals(parentRev.get())) {
+      baseId = destRef.getObjectId();
+      if (baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
-    return ObjectId.fromString(baseRev);
+    return baseId;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index a6ad559..95cd5f1 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -44,7 +44,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -369,7 +369,7 @@
     return new ReviewerAddition(input, notes, user, null, ImmutableList.of(adr), true);
   }
 
-  private boolean isValidReviewer(Branch.NameKey branch, Account member)
+  private boolean isValidReviewer(BranchNameKey branch, Account member)
       throws PermissionBackendException {
     try {
       // Check ref permission instead of change permission, since change permissions take into
@@ -469,8 +469,8 @@
           // New reviewers have value 0, don't bother normalizing.
           result.reviewers.add(
               json.format(
-                  new ReviewerInfo(psa.getAccountId().get()),
-                  psa.getAccountId(),
+                  new ReviewerInfo(psa.accountId().get()),
+                  psa.accountId(),
                   cd,
                   ImmutableList.of(psa)));
         }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 2742bb9..93582f9 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -111,9 +111,9 @@
 
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      LabelType at = labelTypes.byLabel(ca.getLabelId());
+      LabelType at = labelTypes.byLabel(ca.labelId());
       if (at != null) {
-        out.approvals.put(at.getName(), formatValue(ca.getValue()));
+        out.approvals.put(at.getName(), formatValue(ca.value()));
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index aa733cd..f30b321 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -71,7 +72,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -182,7 +182,7 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      ImmutableList<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
       info.webLinks = links.isEmpty() ? null : links;
     }
 
@@ -192,7 +192,7 @@
       i.commit = parent.name();
       i.subject = parent.getShortMessage();
       if (addLinks) {
-        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        ImmutableList<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
         i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
       }
       info.parents.add(i);
@@ -216,7 +216,7 @@
     try (Repository repo = openRepoIfNecessary(cd.project());
         RevWalk rw = newRevWalk(repo)) {
       for (PatchSet in : map.values()) {
-        PatchSet.Id id = in.getId();
+        PatchSet.Id id = in.id();
         boolean want;
         if (has(ALL_REVISIONS)) {
           want = true;
@@ -227,7 +227,7 @@
         }
         if (want) {
           res.put(
-              in.getRevision().get(),
+              in.commitId().name(),
               toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
         }
       }
@@ -251,7 +251,7 @@
 
       String projectName = cd.project().get();
       String url = scheme.getUrl(projectName);
-      String refName = in.getRefName();
+      String refName = in.refName();
       FetchInfo fetchInfo = new FetchInfo(url, refName);
       r.put(schemeName, fetchInfo);
 
@@ -275,14 +275,14 @@
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
     Change c = cd.change();
     RevisionInfo out = new RevisionInfo();
-    out.isCurrent = in.getId().equals(c.currentPatchSetId());
-    out._number = in.getId().get();
-    out.ref = in.getRefName();
-    out.created = in.getCreatedOn();
-    out.uploader = accountLoader.get(in.getUploader());
+    out.isCurrent = in.id().equals(c.currentPatchSetId());
+    out._number = in.id().get();
+    out.ref = in.refName();
+    out.created = in.createdOn();
+    out.uploader = accountLoader.get(in.uploader());
     out.fetch = makeFetchMap(cd, in);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
-    out.description = in.getDescription();
+    out.description = in.description().orElse(null);
 
     boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
     boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
@@ -290,14 +290,14 @@
       checkState(rw != null);
       checkState(repo != null);
       Project.NameKey project = c.getProject();
-      String rev = in.getRevision().get();
+      String rev = in.commitId().name();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
       if (setCommit) {
         out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
       }
       if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().get());
+        Ref ref = repo.exactRef(cd.change().getDest().branch());
         RevCommit mergeTip = null;
         if (ref != null) {
           mergeTip = rw.parseCommit(ref.getObjectId());
@@ -306,7 +306,7 @@
         out.commitWithFooters =
             mergeUtilFactory
                 .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.getId());
+                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.id());
       }
     }
 
@@ -324,10 +324,10 @@
     }
 
     if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
-      if (in.getPushCertificate() != null) {
+      if (in.pushCertificate().isPresent()) {
         out.pushCertificate =
             gpgApi.checkPushCertificate(
-                in.getPushCertificate(), userFactory.create(in.getUploader()));
+                in.pushCertificate().get(), userFactory.create(in.uploader()));
       } else {
         out.pushCertificate = new PushCertificateInfo();
       }
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index caafe24..efd9d2d 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -114,7 +114,7 @@
 
   @Override
   public String toString() {
-    String s = ps.getId().toString();
+    String s = ps.id().toString();
     if (edit.isPresent()) {
       s = "edit:" + s;
     }
@@ -122,6 +122,6 @@
   }
 
   public boolean isCurrent() {
-    return ps.getId().equals(getChange().currentPatchSetId());
+    return ps.id().equals(getChange().currentPatchSetId());
   }
 }
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index 5945a0c..056312c 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -41,7 +41,6 @@
 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;
@@ -223,27 +222,26 @@
     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.id().get() > maxPs.id().get())) {
           maxPs = ps;
         }
       }
       if (maxPs == null) {
         continue; // No patch sets matched.
       }
-      ObjectId id = ObjectId.fromString(maxPs.getRevision().get());
       try {
-        RevCommit c = rw.parseCommit(id);
+        RevCommit c = rw.parseCommit(maxPs.commitId());
         byCommit.put(c, PatchSetData.create(cd, maxPs, c));
       } catch (MissingObjectException | IncorrectObjectTypeException e) {
         logger.atWarning().withCause(e).log(
-            "missing commit %s for patch set %s", id.name(), maxPs.getId());
+            "missing commit %s for patch set %s", maxPs.commitId().name(), maxPs.id());
       }
     }
     return byCommit;
   }
 
   private boolean shouldInclude(PatchSet ps) {
-    return includePatchSets.isEmpty() || includePatchSets.contains(ps.getId());
+    return includePatchSets.isEmpty() || includePatchSets.contains(ps.id());
   }
 
   private static void markStart(RevWalk rw, Iterable<RevCommit> commits) throws IOException {
diff --git a/java/com/google/gerrit/server/config/AllProjectsName.java b/java/com/google/gerrit/server/config/AllProjectsName.java
index 7719e38..06466c4 100644
--- a/java/com/google/gerrit/server/config/AllProjectsName.java
+++ b/java/com/google/gerrit/server/config/AllProjectsName.java
@@ -18,8 +18,6 @@
 
 /** Special name of the project that all projects derive from. */
 public class AllProjectsName extends Project.NameKey {
-  private static final long serialVersionUID = 1L;
-
   public AllProjectsName(String name) {
     super(name);
   }
diff --git a/java/com/google/gerrit/server/config/AllUsersName.java b/java/com/google/gerrit/server/config/AllUsersName.java
index 22d29a4..1b5028a 100644
--- a/java/com/google/gerrit/server/config/AllUsersName.java
+++ b/java/com/google/gerrit/server/config/AllUsersName.java
@@ -18,8 +18,6 @@
 
 /** Special name of the project in which meta data for all users is stored. */
 public class AllUsersName extends Project.NameKey {
-  private static final long serialVersionUID = 1L;
-
   public AllUsersName(String name) {
     super(name);
   }
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index 92ae10a..dfb5c7a 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -316,7 +316,7 @@
 
     @Override
     public void onGitReferenceUpdated(Event event) {
-      Project.NameKey p = new Project.NameKey(event.getProjectName());
+      Project.NameKey p = Project.nameKey(event.getProjectName());
       if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
         return;
       }
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 47b6336..ee95c6f 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -54,6 +54,8 @@
   public final Path secure_config;
   public final Path notedb_config;
 
+  public final Path jgit_config;
+
   public final Path ssl_keystore;
   public final Path ssh_key;
   public final Path ssh_rsa;
@@ -99,6 +101,8 @@
     secure_config = etc_dir.resolve("secure.config");
     notedb_config = etc_dir.resolve("notedb.config");
 
+    jgit_config = etc_dir.resolve("jgit.config");
+
     ssl_keystore = etc_dir.resolve("keystore");
     ssh_key = etc_dir.resolve("ssh_host_key");
     ssh_rsa = etc_dir.resolve("ssh_host_rsa_key");
diff --git a/java/com/google/gerrit/server/edit/ChangeEditJson.java b/java/com/google/gerrit/server/edit/ChangeEditJson.java
index 55e0aef..25dcae0 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -51,8 +51,8 @@
   public EditInfo toEditInfo(ChangeEdit edit, boolean downloadCommands) {
     EditInfo out = new EditInfo();
     out.commit = fillCommit(edit.getEditCommit());
-    out.baseRevision = edit.getBasePatchSet().getRevision().get();
-    out.basePatchSetNumber = edit.getBasePatchSet().getPatchSetId();
+    out.baseRevision = edit.getBasePatchSet().commitId().name();
+    out.basePatchSetNumber = edit.getBasePatchSet().number();
     out.ref = edit.getRefName();
     if (downloadCommands) {
       out.fetch = fillFetchMap(edit);
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 6295e2d..fcd38c3 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -124,7 +124,7 @@
     }
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
-    ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
+    ObjectId patchSetCommitId = currentPatchSet.commitId();
     createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
@@ -157,7 +157,7 @@
       throw new InvalidChangeOperationException(
           String.format(
               "Change edit for change %s is already based on latest patch set %s",
-              notes.getChangeId(), currentPatchSet.getId()));
+              notes.getChangeId(), currentPatchSet.id()));
     }
 
     rebase(repository, changeEdit, currentPatchSet);
@@ -421,10 +421,10 @@
             String.format(
                 "Only the patch set %s on which the existing change edit is based may be modified "
                     + "(specified patch set: %s)",
-                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
+                changeEdit.getBasePatchSet().id(), patchSet.id()));
       }
     } else {
-      PatchSet.Id patchSetId = patchSet.getId();
+      PatchSet.Id patchSetId = patchSet.id();
       PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
       if (!patchSetId.equals(currentPatchSetId)) {
         throw new InvalidChangeOperationException(
@@ -451,12 +451,12 @@
 
   private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
     PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
-    return editBasePatchSet.getId().equals(patchSet.getId());
+    return editBasePatchSet.id().equals(patchSet.id());
   }
 
   private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
       throws IOException {
-    ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    ObjectId patchSetCommitId = patchSet.commitId();
     return lookupCommit(repository, patchSetCommitId);
   }
 
@@ -483,7 +483,7 @@
   private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
       throws IOException, MergeConflictException {
     PatchSet basePatchSet = changeEdit.getBasePatchSet();
-    ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
+    ObjectId basePatchSetCommitId = basePatchSet.commitId();
     ObjectId editCommitId = changeEdit.getEditCommit();
 
     ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repository, true);
@@ -522,10 +522,6 @@
     return user.newCommitterIdent(commitTimestamp, tz);
   }
 
-  private static ObjectId getPatchSetCommitId(PatchSet patchSet) {
-    return ObjectId.fromString(patchSet.getRevision().get());
-  }
-
   private ChangeEdit createEdit(
       Repository repository,
       ChangeNotes notes,
@@ -544,7 +540,7 @@
 
   private String getEditRefName(Change change, PatchSet basePatchSet) {
     IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
+    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.id());
   }
 
   private ChangeEdit updateEdit(
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index cb8147c..1af8148 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -122,7 +122,7 @@
       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.refsEdit(u.getAccountId(), change.getId(), PatchSet.id(change.getId(), i));
       }
       Ref ref = repo.getRefDatabase().firstExactRef(refNames);
       if (ref == null) {
@@ -162,7 +162,7 @@
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
       PatchSet basePatchSet = edit.getBasePatchSet();
-      if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
+      if (!basePatchSet.id().equals(change.currentPatchSetId())) {
         throw new ResourceConflictException("only edit for current patch set can be published");
       }
 
@@ -174,17 +174,14 @@
           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());
+      ObjectId prior = basePatchSet.commitId();
       ChangeKind kind =
           changeKindCache.getChangeKind(change.getProject(), rw, repo.getConfig(), 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(".");
+        message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
       }
 
       try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.nowTs())) {
@@ -223,7 +220,7 @@
       int pos = ref.getName().lastIndexOf('/');
       checkArgument(pos > 0, "invalid edit ref: %s", ref.getName());
       String psId = ref.getName().substring(pos + 1);
-      return psUtil.get(notes, new PatchSet.Id(notes.getChange().getId(), Integer.parseInt(psId)));
+      return psUtil.get(notes, PatchSet.id(notes.getChange().getId(), Integer.parseInt(psId)));
     } catch (StorageException | NumberFormatException e) {
       throw new IOException(e);
     }
@@ -232,7 +229,7 @@
   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(basePatchSet.commitId());
     if (parent.getTree().equals(edit.getTree())
         && edit.getFullMessage().equals(parent.getFullMessage())) {
       throw new ResourceConflictException("identical tree and message");
diff --git a/java/com/google/gerrit/server/events/ChangeEvent.java b/java/com/google/gerrit/server/events/ChangeEvent.java
index 6029ded..95fdd77 100644
--- a/java/com/google/gerrit/server/events/ChangeEvent.java
+++ b/java/com/google/gerrit/server/events/ChangeEvent.java
@@ -29,7 +29,7 @@
   protected ChangeEvent(String type, Change change) {
     super(type);
     this.project = change.getProject();
-    this.refName = RefNames.fullName(change.getDest().get());
+    this.refName = RefNames.fullName(change.getDest().branch());
     this.changeKey = change.getKey();
   }
 
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index d5f548f..f5af8ac 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -18,9 +18,10 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSet.Id;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -34,6 +35,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -47,6 +49,8 @@
     protected void configure() {
       DynamicItem.itemOf(binder(), EventDispatcher.class);
       DynamicItem.bind(binder(), EventDispatcher.class).to(EventBroker.class);
+
+      bind(Gson.class).annotatedWith(EventGson.class).toProvider(EventGsonProvider.class);
     }
   }
 
@@ -81,7 +85,7 @@
   }
 
   @Override
-  public void postEvent(Branch.NameKey branchName, RefEvent event)
+  public void postEvent(BranchNameKey branchName, RefEvent event)
       throws PermissionBackendException {
     fireEvent(branchName, event);
   }
@@ -120,7 +124,7 @@
     fireEventForUnrestrictedListeners(event);
   }
 
-  protected void fireEvent(Branch.NameKey branchName, RefEvent event)
+  protected void fireEvent(BranchNameKey branchName, RefEvent event)
       throws PermissionBackendException {
     for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
       CurrentUser user = c.call(UserScopedEventListener::getUser);
@@ -174,9 +178,9 @@
     }
   }
 
-  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user)
+  protected boolean isVisibleTo(BranchNameKey branchName, CurrentUser user)
       throws PermissionBackendException {
-    ProjectState pe = projectCache.get(branchName.getParentKey());
+    ProjectState pe = projectCache.get(branchName.project());
     if (pe == null || !pe.statePermitsRead()) {
       return false;
     }
@@ -194,13 +198,13 @@
       RefEvent refEvent = (RefEvent) event;
       String ref = refEvent.getRefName();
       if (PatchSet.isChangeRef(ref)) {
-        Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
+        Change.Id cid = Id.fromRef(ref).changeId();
         try {
           Change change = notesFactory.createChecked(refEvent.getProjectNameKey(), cid).getChange();
           return isVisibleTo(change, user);
         } catch (NoSuchChangeException e) {
           logger.atFine().log(
-              "Change %s cannot be found, falling back on ref visibility check", cid.id);
+              "Change %s cannot be found, falling back on ref visibility check", cid.get());
         }
       }
       return isVisibleTo(refEvent.getBranchNameKey(), user);
diff --git a/java/com/google/gerrit/server/events/EventDispatcher.java b/java/com/google/gerrit/server/events/EventDispatcher.java
index e6735f2..ab84acc 100644
--- a/java/com/google/gerrit/server/events/EventDispatcher.java
+++ b/java/com/google/gerrit/server/events/EventDispatcher.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -37,7 +37,7 @@
    * @param event The event to post
    * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Branch.NameKey branchName, RefEvent event) throws PermissionBackendException;
+  void postEvent(BranchNameKey branchName, RefEvent event) throws PermissionBackendException;
 
   /**
    * Post a stream event that is related to a project.
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index efd9bb9..b57dacb 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -127,7 +127,7 @@
   public ChangeAttribute asChangeAttribute(Change change) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
-    a.branch = change.getDest().getShortName();
+    a.branch = change.getDest().shortName();
     a.topic = change.getTopic();
     a.id = change.getKey().get();
     a.number = change.getId().get();
@@ -174,12 +174,12 @@
    * @return object suitable for serialization to JSON
    */
   public RefUpdateAttribute asRefUpdateAttribute(
-      ObjectId oldId, ObjectId newId, Branch.NameKey refName) {
+      ObjectId oldId, ObjectId newId, BranchNameKey refName) {
     RefUpdateAttribute ru = new RefUpdateAttribute();
     ru.newRev = newId != null ? newId.getName() : ObjectId.zeroId().getName();
     ru.oldRev = oldId != null ? oldId.getName() : ObjectId.zeroId().getName();
-    ru.project = refName.getParentKey().get();
-    ru.refName = refName.get();
+    ru.project = refName.project().get();
+    ru.refName = refName.branch();
     return ru;
   }
 
@@ -285,7 +285,7 @@
 
   private void addDependsOn(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
       throws IOException {
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(currentPs.getRevision().get()));
+    RevCommit commit = rw.parseCommit(currentPs.commitId());
     final List<String> parentNames = new ArrayList<>(commit.getParentCount());
     for (RevCommit p : commit.getParents()) {
       parentNames.add(p.name());
@@ -296,7 +296,7 @@
     for (ChangeData cd : queryProvider.get().byProjectCommits(change.getProject(), parentNames)) {
       for (PatchSet ps : cd.patchSets()) {
         for (String p : parentNames) {
-          if (!ps.getRevision().get().equals(p)) {
+          if (!ps.commitId().name().equals(p)) {
             continue;
           }
           ca.dependsOn.add(newDependsOn(requireNonNull(cd.change()), ps));
@@ -318,18 +318,18 @@
 
   private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
       throws IOException {
-    if (currentPs.getGroups().isEmpty()) {
+    if (currentPs.groups().isEmpty()) {
       return;
     }
-    String rev = currentPs.getRevision().get();
+    String rev = currentPs.commitId().name();
     // 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 :
         InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, change.getProject(), currentPs.getGroups())) {
+            queryProvider, indexConfig, change.getProject(), currentPs.groups())) {
       PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
-        RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        RevCommit commit = rw.parseCommit(ps.commitId());
         for (RevCommit p : commit.getParents()) {
           if (!p.name().equals(rev)) {
             continue;
@@ -343,7 +343,7 @@
 
   private DependencyAttribute newDependsOn(Change c, PatchSet ps) {
     DependencyAttribute d = newDependencyAttribute(c, ps);
-    d.isCurrentPatchSet = ps.getId().equals(c.currentPatchSetId());
+    d.isCurrentPatchSet = ps.id().equals(c.currentPatchSetId());
     return d;
   }
 
@@ -355,8 +355,8 @@
     DependencyAttribute d = new DependencyAttribute();
     d.number = c.getId().get();
     d.id = c.getKey().toString();
-    d.revision = ps.getRevision().get();
-    d.ref = ps.getRefName();
+    d.revision = ps.commitId().name();
+    d.ref = ps.refName();
     return d;
   }
 
@@ -400,7 +400,7 @@
       for (PatchSet p : ps) {
         PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p);
         if (approvals != null) {
-          addApprovals(psa, p.getId(), approvals, labelTypes);
+          addApprovals(psa, p.id(), approvals, labelTypes);
         }
         ca.patchSets.add(psa);
         if (includeFiles) {
@@ -463,12 +463,12 @@
    */
   public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
     PatchSetAttribute p = new PatchSetAttribute();
-    p.revision = patchSet.getRevision().get();
-    p.number = patchSet.getPatchSetId();
-    p.ref = patchSet.getRefName();
-    p.uploader = asAccountAttribute(patchSet.getUploader());
-    p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
-    PatchSet.Id pId = patchSet.getId();
+    p.revision = patchSet.commitId().name();
+    p.number = patchSet.number();
+    p.ref = patchSet.refName();
+    p.uploader = asAccountAttribute(patchSet.uploader());
+    p.createdOn = patchSet.createdOn().getTime() / 1000L;
+    PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
       RevCommit c = revWalk.parseCommit(ObjectId.fromString(p.revision));
@@ -495,7 +495,7 @@
       }
       p.kind = changeKindCache.getChangeKind(change, patchSet);
     } catch (IOException | StorageException e) {
-      logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.getId());
+      logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
     } catch (PatchListObjectTooLargeException e) {
       logger.atWarning().log("Cannot get size information for %s: %s", pId, e.getMessage());
     } catch (PatchListNotAvailableException e) {
@@ -540,7 +540,7 @@
     if (!list.isEmpty()) {
       p.approvals = new ArrayList<>(list.size());
       for (PatchSetApproval a : list) {
-        if (a.getValue() != 0) {
+        if (a.value() != 0) {
           p.approvals.add(asApprovalAttribute(a, labelTypes));
         }
       }
@@ -599,13 +599,13 @@
    */
   public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
     ApprovalAttribute a = new ApprovalAttribute();
-    a.type = approval.getLabelId().get();
-    a.value = Short.toString(approval.getValue());
-    a.by = asAccountAttribute(approval.getAccountId());
-    a.grantedOn = approval.getGranted().getTime() / 1000L;
+    a.type = approval.labelId().get();
+    a.value = Short.toString(approval.value());
+    a.by = asAccountAttribute(approval.accountId());
+    a.grantedOn = approval.granted().getTime() / 1000L;
     a.oldValue = null;
 
-    LabelType lt = labelTypes.byLabel(approval.getLabelId());
+    LabelType lt = labelTypes.byLabel(approval.labelId());
     if (lt != null) {
       a.description = lt.getName();
     }
diff --git a/java/com/google/gerrit/server/events/EventGson.java b/java/com/google/gerrit/server/events/EventGson.java
new file mode 100644
index 0000000..87b45f6
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventGson.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@BindingAnnotation
+@Retention(RUNTIME)
+@Target({PARAMETER, FIELD})
+public @interface EventGson {}
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
new file mode 100644
index 0000000..2fe526b
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeKeyAdapter;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Provider;
+
+public class EventGsonProvider implements Provider<Gson> {
+  @Override
+  public Gson get() {
+    return new GsonBuilder()
+        .registerTypeAdapter(Event.class, new EventDeserializer())
+        .registerTypeAdapter(Supplier.class, new SupplierSerializer())
+        .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
+        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
+        .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+        .create();
+  }
+}
diff --git a/java/com/google/gerrit/server/events/ProjectCreatedEvent.java b/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
index dc979ca..42b6676 100644
--- a/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
+++ b/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
@@ -27,7 +27,7 @@
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(projectName);
+    return Project.nameKey(projectName);
   }
 
   public String getHeadName() {
diff --git a/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java b/java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
similarity index 63%
rename from java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
rename to java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
index 743b314..29f2768 100644
--- a/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
+++ b/java/com/google/gerrit/server/events/ProjectNameKeyAdapter.java
@@ -15,16 +15,30 @@
 package com.google.gerrit.server.events;
 
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
 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 ProjectNameKeyAdapter
+    implements JsonSerializer<Project.NameKey>, JsonDeserializer<Project.NameKey> {
   @Override
   public JsonElement serialize(
       Project.NameKey project, Type typeOfSrc, JsonSerializationContext context) {
     return new JsonPrimitive(project.get());
   }
+
+  @Override
+  public Project.NameKey deserialize(
+      JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.isJsonPrimitive() || !json.getAsJsonPrimitive().isString()) {
+      throw new JsonParseException("Key is not a string: " + json);
+    }
+    return Project.nameKey(json.getAsString());
+  }
 }
diff --git a/java/com/google/gerrit/server/events/RefEvent.java b/java/com/google/gerrit/server/events/RefEvent.java
index 951940f..3a8d246 100644
--- a/java/com/google/gerrit/server/events/RefEvent.java
+++ b/java/com/google/gerrit/server/events/RefEvent.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.events;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 
 public abstract class RefEvent extends ProjectEvent {
   protected RefEvent(String type) {
     super(type);
   }
 
-  public Branch.NameKey getBranchNameKey() {
-    return new Branch.NameKey(getProjectNameKey(), getRefName());
+  public BranchNameKey getBranchNameKey() {
+    return BranchNameKey.create(getProjectNameKey(), getRefName());
   }
 
   public abstract String getRefName();
diff --git a/java/com/google/gerrit/server/events/RefUpdatedEvent.java b/java/com/google/gerrit/server/events/RefUpdatedEvent.java
index d740543..fa16c4c 100644
--- a/java/com/google/gerrit/server/events/RefUpdatedEvent.java
+++ b/java/com/google/gerrit/server/events/RefUpdatedEvent.java
@@ -30,7 +30,7 @@
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(refUpdate.get().project);
+    return Project.nameKey(refUpdate.get().project);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 3add4ca..85ef149 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -43,7 +43,7 @@
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.PatchSetUtil;
@@ -137,7 +137,7 @@
 
   private ChangeNotes getNotes(ChangeInfo info) {
     try {
-      return changeNotesFactory.createChecked(new Change.Id(info._number));
+      return changeNotesFactory.createChecked(Change.id(info._number));
     } catch (NoSuchChangeException e) {
       throw new StorageException(e);
     }
@@ -162,7 +162,7 @@
     return Suppliers.memoize(
         () ->
             account != null
-                ? eventFactory.asAccountAttribute(new Account.Id(account._accountId))
+                ? eventFactory.asAccountAttribute(Account.id(account._accountId))
                 : null);
   }
 
@@ -360,7 +360,7 @@
     if (ev.getUpdater() != null) {
       event.submitter = accountAttributeSupplier(ev.getUpdater());
     }
-    final Branch.NameKey refName = new Branch.NameKey(ev.getProjectName(), ev.getRefName());
+    final BranchNameKey refName = BranchNameKey.create(ev.getProjectName(), ev.getRefName());
     event.refUpdate =
         Suppliers.memoize(
             () ->
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index d00eb31..7122a4c 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -84,7 +84,7 @@
 
   public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
-    ChangeData cd = changeDataFactory.create(project, ps.getId().getParentKey());
+    ChangeData cd = changeDataFactory.create(project, ps.id().changeId());
     return revisionJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(cd, ps);
   }
 
diff --git a/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 3ca2bdb..be64d54 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -19,7 +19,6 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Predicate;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -50,6 +49,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Predicate;
 
 @Singleton
 public class UiActions {
diff --git a/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
index 7d4edcf..85c700a 100644
--- a/java/com/google/gerrit/server/git/ChangeMessageModifier.java
+++ b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -47,5 +47,5 @@
    * @return a new not null commit message.
    */
   String onSubmit(
-      String newCommitMessage, RevCommit original, RevCommit mergeTip, Branch.NameKey destination);
+      String newCommitMessage, RevCommit original, RevCommit mergeTip, BranchNameKey destination);
 }
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index c210dcd..4c6be20 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -48,7 +48,7 @@
       Ordering.natural()
           .onResultOf(
               (CodeReviewCommit c) ->
-                  c.getPatchsetId() != null ? c.getPatchsetId().getParentKey().get() : null)
+                  c.getPatchsetId() != null ? c.getPatchsetId().changeId().get() : null)
           .nullsFirst();
 
   public static CodeReviewRevWalk newRevWalk(Repository repo) {
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 07c995f..c7dcc73 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -82,9 +82,9 @@
     if (rsrc.getEdit().isPresent()) {
       // Groups for an edit are just the base revision's groups, since they have
       // the same parent.
-      return rsrc.getEdit().get().getBasePatchSet().getGroups();
+      return rsrc.getEdit().get().getBasePatchSet().groups();
     }
-    return rsrc.getPatchSet().getGroups();
+    return rsrc.getPatchSet().groups();
   }
 
   private interface Lookup {
@@ -107,9 +107,9 @@
         transformRefs(changeRefsById),
         psId -> {
           // TODO(dborowitz): Reuse open repository from caller.
-          ChangeNotes notes = notesFactory.createChecked(project, psId.getParentKey());
+          ChangeNotes notes = notesFactory.createChecked(project, psId.changeId());
           PatchSet ps = psUtil.get(notes, psId);
-          return ps != null ? ps.getGroups() : null;
+          return ps != null ? ps.groups() : null;
         });
   }
 
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 85822a8..9646fc7 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -254,7 +254,7 @@
       int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
       projectName = projectName.substring(0, newLen);
     }
-    return new Project.NameKey(projectName);
+    return Project.nameKey(projectName);
   }
 
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 5e2ad47..4088c81 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
@@ -40,7 +41,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -117,6 +118,13 @@
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /**
+   * Length of abbreviated hex SHA-1s in merged filenames.
+   *
+   * <p>This is a constant so output is stable over time even if the SHA-1 prefix becomes ambiguous.
+   */
+  private static final int NAME_ABBREV_LEN = 6;
+
   static class PluggableCommitMessageGenerator {
     private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
@@ -126,7 +134,7 @@
     }
 
     public String generate(
-        RevCommit original, RevCommit mergeTip, Branch.NameKey dest, String originalMessage) {
+        RevCommit original, RevCommit mergeTip, BranchNameKey dest, String originalMessage) {
       requireNonNull(original.getRawBuffer());
       if (mergeTip != null) {
         requireNonNull(mergeTip.getRawBuffer());
@@ -337,13 +345,13 @@
         String.format(
             "%0$-" + nameLength + "s (%s %s)",
             oursName,
-            ours.abbreviate(6).name(),
+            abbreviateName(ours, NAME_ABBREV_LEN),
             oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
     String theirsNameFormatted =
         String.format(
             "%0$-" + nameLength + "s (%s %s)",
             theirsName,
-            theirs.abbreviate(6).name(),
+            abbreviateName(theirs, NAME_ABBREV_LEN),
             theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
 
     MergeFormatter fmt = new MergeFormatter();
@@ -514,7 +522,7 @@
     PatchSetApproval submitAudit = null;
 
     for (PatchSetApproval a : safeGetApprovals(notes, psId)) {
-      if (a.getValue() <= 0) {
+      if (a.value() <= 0) {
         // Negative votes aren't counted.
         continue;
       }
@@ -522,13 +530,13 @@
       if (a.isLegacySubmit()) {
         // Submit is treated specially, below (becomes committer)
         //
-        if (submitAudit == null || a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
+        if (submitAudit == null || a.granted().compareTo(submitAudit.granted()) > 0) {
           submitAudit = a;
         }
         continue;
       }
 
-      final Account acc = identifiedUserFactory.create(a.getAccountId()).getAccount();
+      final Account acc = identifiedUserFactory.create(a.accountId()).getAccount();
       final StringBuilder identbuf = new StringBuilder();
       if (acc.getFullName() != null && acc.getFullName().length() > 0) {
         if (identbuf.length() > 0) {
@@ -553,12 +561,12 @@
       }
 
       final String tag;
-      if (isCodeReview(a.getLabelId())) {
+      if (isCodeReview(a.labelId())) {
         tag = "Reviewed-by";
-      } else if (isVerified(a.getLabelId())) {
+      } else if (isVerified(a.labelId())) {
         tag = "Tested-by";
       } else {
-        final LabelType lt = project.getLabelTypes().byLabel(a.getLabelId());
+        final LabelType lt = project.getLabelTypes().byLabel(a.labelId());
         if (lt == null) {
           continue;
         }
@@ -733,7 +741,7 @@
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
       Config repoConfig,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
       throws IntegrationException {
@@ -788,7 +796,7 @@
       PersonIdent committer,
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       ObjectId treeId,
       CodeReviewCommit n)
@@ -805,9 +813,9 @@
     }
 
     StringBuilder msgbuf = new StringBuilder().append(summarize(rw, merged));
-    if (!R_HEADS_MASTER.equals(destBranch.get())) {
+    if (!R_HEADS_MASTER.equals(destBranch.branch())) {
       msgbuf.append(" into ");
-      msgbuf.append(destBranch.getShortName());
+      msgbuf.append(destBranch.shortName());
     }
 
     if (merged.size() > 1) {
@@ -974,7 +982,7 @@
         if (c.getPatchsetId() == null) {
           continue;
         }
-        Change.Id id = c.getPatchsetId().getParentKey();
+        Change.Id id = c.getPatchsetId().changeId();
         if (!expected.contains(id)) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 43dc061..a217e64 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -21,9 +21,7 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
@@ -42,7 +40,6 @@
 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;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -106,7 +103,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx) throws IOException {
     change = ctx.getChange();
-    correctBranch = refName.equals(change.getDest().get());
+    correctBranch = refName.equals(change.getDest().branch());
     if (!correctBranch) {
       return false;
     }
@@ -135,7 +132,7 @@
     update.setCurrentPatchSet();
     StringBuilder msgBuf = new StringBuilder();
     msgBuf.append("Change has been successfully pushed");
-    if (!refName.equals(change.getDest().get())) {
+    if (!refName.equals(change.getDest().branch())) {
       msgBuf.append(" into ");
       if (refName.startsWith(Constants.R_HEADS)) {
         msgBuf.append("branch ");
@@ -149,12 +146,7 @@
         ChangeMessagesUtil.newMessage(
             psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
     cmUtil.addChangeMessage(update, msg);
-
-    PatchSetApproval submitter =
-        ApprovalsUtil.newApproval(
-            change.currentPatchSetId(), ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
-    update.putApproval(submitter.getLabel(), submitter.getValue());
-
+    update.putApproval(LabelId.legacySubmit().get(), (short) 1);
     return true;
   }
 
@@ -172,7 +164,7 @@
                   public void run() {
                     try {
                       MergedSender cm =
-                          mergedSenderFactory.create(ctx.getProject(), psId.getParentKey());
+                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
                       cm.setFrom(ctx.getAccountId());
                       cm.setPatchSet(patchSet, info);
                       cm.send();
@@ -189,13 +181,12 @@
                 }));
 
     changeMerged.fire(
-        change, patchSet, ctx.getAccount(), patchSet.getRevision().get(), ctx.getWhen());
+        change, patchSet, ctx.getAccount(), patchSet.commitId().name(), ctx.getWhen());
   }
 
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
     RevWalk rw = ctx.getRevWalk();
-    RevCommit commit =
-        rw.parseCommit(ObjectId.fromString(requireNonNull(patchSet).getRevision().get()));
+    RevCommit commit = rw.parseCommit(requireNonNull(patchSet).commitId());
     return patchSetInfoFactory.get(rw, commit, psId);
   }
 }
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 1daa1d5..2faa0bb 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -97,8 +97,8 @@
             claimedRevert.getProjectName(), claimedRevert.getChange().getRevertOf());
     return isPureRevert(
         claimedRevert.getProjectName(),
-        ObjectId.fromString(claimedRevert.getCurrentPatchSet().getRevision().get()),
-        ObjectId.fromString(claimedOriginal.getCurrentPatchSet().getRevision().get()));
+        claimedRevert.getCurrentPatchSet().commitId(),
+        claimedOriginal.getCurrentPatchSet().commitId());
   }
 
   /**
@@ -154,7 +154,7 @@
           TraceContext.newTimer("Loading pure revert for %s", key)) {
         ObjectId original = ObjectIdConverter.create().fromByteString(key.getClaimedOriginal());
         ObjectId revert = ObjectIdConverter.create().fromByteString(key.getClaimedRevert());
-        Project.NameKey project = new Project.NameKey(key.getProject());
+        Project.NameKey project = Project.nameKey(key.getProject());
 
         try (Repository repo = repoManager.openRepository(project);
             ObjectInserter oi = repo.newObjectInserter();
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index d7f8982..fb7756f 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -136,7 +136,7 @@
   @Override
   public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
     if (event.getRefName().startsWith(RefNames.REFS_CHANGES)) {
-      cache.invalidate(new Project.NameKey(event.getProjectName()));
+      cache.invalidate(Project.nameKey(event.getProjectName()));
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/SystemReaderInstaller.java b/java/com/google/gerrit/server/git/SystemReaderInstaller.java
new file mode 100644
index 0000000..520ede4
--- /dev/null
+++ b/java/com/google/gerrit/server/git/SystemReaderInstaller.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.server.git;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+
+@Singleton
+public class SystemReaderInstaller implements LifecycleListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SitePaths site;
+
+  @Inject
+  SystemReaderInstaller(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public void start() {
+    SystemReader.setInstance(customReader());
+    logger.atInfo().log("Set JGit's SystemReader to read system config from %s", site.jgit_config);
+  }
+
+  @Override
+  public void stop() {}
+
+  private SystemReader customReader() {
+    SystemReader current = SystemReader.getInstance();
+
+    FileBasedConfig jgitConfig = new FileBasedConfig(site.jgit_config.toFile(), FS.DETECTED);
+
+    return new SystemReader() {
+      @Override
+      public String getHostname() {
+        return current.getHostname();
+      }
+
+      @Override
+      public String getenv(String variable) {
+        return current.getenv(variable);
+      }
+
+      @Override
+      public String getProperty(String key) {
+        return current.getProperty(key);
+      }
+
+      @Override
+      public FileBasedConfig openUserConfig(Config parent, FS fs) {
+        return current.openSystemConfig(parent, fs);
+      }
+
+      @Override
+      public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+        return jgitConfig;
+      }
+
+      @Override
+      public long getCurrentTime() {
+        return current.getCurrentTime();
+      }
+
+      @Override
+      public int getTimezone(long when) {
+        return current.getTimezone(when);
+      }
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 57637c89..860118c 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -232,7 +232,7 @@
                     new Tag(
                         idConverter.fromByteString(t.getId()),
                         BitSet.valueOf(t.getFlags().asReadOnlyByteBuffer()))));
-    return new TagSet(new Project.NameKey(proto.getProjectName()), refs, tags);
+    return new TagSet(Project.nameKey(proto.getProjectName()), refs, tags);
   }
 
   TagSetProto toProto() {
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index 194283e..d1e33ba 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -117,7 +117,7 @@
     @Override
     public TagSetHolder deserialize(byte[] in) {
       TagSetHolderProto proto = Protos.parseUnchecked(TagSetHolderProto.parser(), in);
-      TagSetHolder holder = new TagSetHolder(new Project.NameKey(proto.getProjectName()));
+      TagSetHolder holder = new TagSetHolder(Project.nameKey(proto.getProjectName()));
       if (proto.hasTags()) {
         holder.tags = TagSet.fromProto(proto.getTags());
       }
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index f109570..9506efc 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -19,6 +19,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -109,7 +110,7 @@
   /** @return revision of the metadata that was loaded. */
   @Nullable
   public ObjectId getRevision() {
-    return revision != null ? revision.copy() : null;
+    return ObjectIds.copyOrNull(revision);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index da21fbb..a4f4d93 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index 64f54d6..fe7ff86 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.server.git.receive;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.server.IdentifiedUser;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -48,12 +48,12 @@
   private final IdentifiedUser user;
   private final PermissionBackend.ForProject permissions;
   private final Project project;
-  private final Branch.NameKey branch;
+  private final BranchNameKey branch;
   private final SshInfo sshInfo;
 
   interface Factory {
     BranchCommitValidator create(
-        ProjectState projectState, Branch.NameKey branch, IdentifiedUser user);
+        ProjectState projectState, BranchNameKey branch, IdentifiedUser user);
   }
 
   @Inject
@@ -62,7 +62,7 @@
       PermissionBackend permissionBackend,
       SshInfo sshInfo,
       @Assisted ProjectState projectState,
-      @Assisted Branch.NameKey branch,
+      @Assisted BranchNameKey branch,
       @Assisted IdentifiedUser user) {
     this.sshInfo = sshInfo;
     this.user = user;
@@ -91,7 +91,7 @@
       @Nullable Change change)
       throws IOException {
     try (CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, branch.get(), objectReader, commit, user)) {
+        new CommitReceivedEvent(cmd, project, branch.branch(), objectReader, commit, user)) {
       CommitValidators validators;
       if (isMerged) {
         validators =
@@ -110,7 +110,8 @@
 
       for (CommitValidationMessage m : validators.validate(receiveEvent)) {
         messages.add(
-            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.getType()));
+            new CommitValidationMessage(
+                messageForCommit(commit, m.getMessage(), objectReader), m.getType()));
       }
     } catch (CommitValidationException e) {
       logger.atFine().log("Commit validation failed on %s", commit.name());
@@ -118,15 +119,17 @@
         // The non-error messages may contain background explanation for the
         // fatal error, so have to preserve all messages.
         messages.add(
-            new CommitValidationMessage(messageForCommit(commit, m.getMessage()), m.getType()));
+            new CommitValidationMessage(
+                messageForCommit(commit, m.getMessage(), objectReader), m.getType()));
       }
-      cmd.setResult(REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage()));
+      cmd.setResult(REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage(), objectReader));
       return false;
     }
     return true;
   }
 
-  private String messageForCommit(RevCommit c, String msg) {
-    return String.format("commit %s: %s", c.abbreviate(RevId.ABBREV_LEN).name(), msg);
+  private String messageForCommit(RevCommit c, String msg, ObjectReader objectReader)
+      throws IOException {
+    return String.format("commit %s: %s", abbreviateName(c, objectReader), msg);
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
index 9bad21d..251a799 100644
--- a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
+++ b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
@@ -18,12 +18,12 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.ObjectIds;
 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;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -49,7 +49,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   /** 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 + ObjectIds.STR_LEN + 1 + 5 + 1;
 
   /**
    * Maximum number of bytes to "waste" in the advertisement with a peek at this repository's
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 6ffad7b..61249b1 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -20,6 +20,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.isConfigRef;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
@@ -40,7 +41,6 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
 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.Splitter;
 import com.google.common.base.Strings;
@@ -81,13 +81,12 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 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.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
@@ -275,16 +274,14 @@
     }
   }
 
-  private static final Function<Exception, RestApiException> INSERT_EXCEPTION =
-      input -> {
-        if (input instanceof RestApiException) {
-          return (RestApiException) input;
-        } else if ((input instanceof ExecutionException)
-            && (input.getCause() instanceof RestApiException)) {
-          return (RestApiException) input.getCause();
-        }
-        return new RestApiException("Error inserting change/patchset", input);
-      };
+  private static RestApiException asRestApiException(Exception e) {
+    if (e instanceof RestApiException) {
+      return (RestApiException) e;
+    } else if ((e instanceof ExecutionException) && (e.getCause() instanceof RestApiException)) {
+      return (RestApiException) e.getCause();
+    }
+    return new RestApiException("Error inserting change/patchset", e);
+  }
 
   // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
   // somewhat, and kept sorted lexicographically within sections, except where later assignments
@@ -660,7 +657,7 @@
       logger.atFine().withCause(e).log("update failed:");
     }
 
-    Set<Branch.NameKey> branches = new HashSet<>();
+    Set<BranchNameKey> branches = new HashSet<>();
     for (ReceiveCommand c : cmds) {
       // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
       // should happen in this loop are things that can't happen within one BatchUpdate because
@@ -676,7 +673,7 @@
             Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
             autoCloseChanges(c, closeProgress);
             closeProgress.end();
-            branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
+            branches.add(BranchNameKey.create(project.getNameKey(), c.getRefName()));
             break;
 
           case DELETE:
@@ -855,7 +852,7 @@
       try {
         bu.execute();
       } catch (UpdateException e) {
-        throw INSERT_EXCEPTION.apply(e);
+        throw asRestApiException(e);
       }
 
       replaceByChange.values().stream()
@@ -1226,7 +1223,7 @@
       return;
     }
 
-    Branch.NameKey branch = new Branch.NameKey(project.getName(), cmd.getRefName());
+    BranchNameKey branch = BranchNameKey.create(project.getName(), cmd.getRefName());
     try {
       // Must pass explicit user instead of injecting a provider into CreateRefControl, since
       // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
@@ -1240,7 +1237,7 @@
     }
 
     if (validRefOperation(cmd)) {
-      validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      validateRegularPushCommits(BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
     }
   }
 
@@ -1253,7 +1250,8 @@
         return;
       }
       if (validRefOperation(cmd)) {
-        validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+        validateRegularPushCommits(
+            BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
       }
     } else {
       rejectProhibited(cmd, err.get());
@@ -1312,7 +1310,7 @@
     logger.atFine().log("Rewinding %s", cmd);
 
     if (newObject != null) {
-      validateRegularPushCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      validateRegularPushCommits(BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
       if (cmd.getResult() != NOT_ATTEMPTED) {
         return;
       }
@@ -1367,7 +1365,7 @@
     final ReceiveCommand cmd;
     final LabelTypes labelTypes;
     private final boolean defaultPublishComments;
-    Branch.NameKey dest;
+    BranchNameKey dest;
     PermissionBackend.ForRef perm;
     Set<String> reviewer = Sets.newLinkedHashSet();
     Set<String> cc = Sets.newLinkedHashSet();
@@ -1705,7 +1703,7 @@
       return;
     }
 
-    magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
+    magicBranch.dest = BranchNameKey.create(project.getNameKey(), ref);
     magicBranch.perm = permissions.ref(ref);
 
     Optional<AuthException> err = checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
@@ -1768,7 +1766,7 @@
       return;
     }
 
-    String destBranch = magicBranch.dest.get();
+    String destBranch = magicBranch.dest.branch();
     try {
       if (magicBranch.merged) {
         if (magicBranch.base != null) {
@@ -1777,7 +1775,7 @@
         }
         RevCommit branchTip = readBranchTip(magicBranch.dest);
         if (branchTip == null) {
-          reject(cmd, magicBranch.dest.get() + " not found");
+          reject(cmd, magicBranch.dest.branch() + " not found");
           return;
         }
         if (!walk.isMergedInto(tip, branchTip)) {
@@ -1827,7 +1825,7 @@
           // branch does not exist yet. This allows to push initial code for review to an empty
           // repository and to review an initial project configuration.
           if (!ref.equals(readHEAD(repo)) && !ref.equals(RefNames.REFS_CONFIG)) {
-            reject(cmd, magicBranch.dest.get() + " not found");
+            reject(cmd, magicBranch.dest.branch() + " not found");
             return;
           }
         }
@@ -1856,10 +1854,10 @@
   // branch.  If they aren't, we want to abort. We do this check by
   // looking to see if we can compute a merge base between the new
   // commits and the target branch head.
-  private boolean validateConnected(ReceiveCommand cmd, Branch.NameKey dest, RevCommit tip) {
+  private boolean validateConnected(ReceiveCommand cmd, BranchNameKey dest, RevCommit tip) {
     RevWalk walk = receivePack.getRevWalk();
     try {
-      Ref targetRef = receivePack.getAdvertisedRefs().get(dest.get());
+      Ref targetRef = receivePack.getAdvertisedRefs().get(dest.branch());
       if (targetRef == null || targetRef.getObjectId() == null) {
         // The destination branch does not yet exist. Assume the
         // history being sent for review will start it and thus
@@ -1905,8 +1903,8 @@
     }
   }
 
-  private RevCommit readBranchTip(Branch.NameKey branch) throws IOException {
-    Ref r = allRefs().get(branch.get());
+  private RevCommit readBranchTip(BranchNameKey branch) throws IOException {
+    Ref r = allRefs().get(branch.branch());
     if (r == null) {
       return null;
     }
@@ -2087,8 +2085,7 @@
 
         List<String> idList = c.getFooterLines(CHANGE_ID);
         if (!idList.isEmpty()) {
-          pending.put(
-              c, lookupByChangeKey(c, new Change.Key(idList.get(idList.size() - 1).trim())));
+          pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim())));
         } else {
           pending.put(c, lookupByCommit(c));
         }
@@ -2141,7 +2138,7 @@
         }
 
         if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get(), newProgress));
+          newChanges.add(new CreateRequest(c, magicBranch.dest.branch(), newProgress));
           continue;
         }
       }
@@ -2187,9 +2184,9 @@
           // Schedule as a replacement to this one matching change.
           //
 
-          RevId currentPs = changes.get(0).currentPatchSet().getRevision();
+          ObjectId currentPs = changes.get(0).currentPatchSet().commitId();
           // If Commit is already current PatchSet of target Change.
-          if (p.commit.name().equals(currentPs.get())) {
+          if (p.commit.equals(currentPs)) {
             if (pending.size() == 1) {
               // There are no commits left to check, all commits in pending were already
               // current PatchSet of the corresponding target changes.
@@ -2225,7 +2222,7 @@
           }
           newChangeIds.add(p.changeKey);
         }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get(), newProgress));
+        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.branch(), newProgress));
       }
       logger.atFine().log(
           "Finished deferred lookups with %d updates and %d new changes",
@@ -2306,7 +2303,7 @@
         rw.markUninteresting(c);
       }
     } else {
-      markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.get() : null);
+      markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.branch() : null);
     }
     return start;
   }
@@ -2316,11 +2313,11 @@
     for (RevCommit c : magicBranch.baseCommit) {
       receivePack.getRevWalk().markUninteresting(c);
     }
-    Ref targetRef = allRefs().get(magicBranch.dest.get());
+    Ref targetRef = allRefs().get(magicBranch.dest.branch());
     if (targetRef != null) {
       logger.atFine().log(
           "Marking target ref %s (%s) uninteresting",
-          magicBranch.dest.get(), targetRef.getObjectId().name());
+          magicBranch.dest.branch(), targetRef.getObjectId().name());
       receivePack
           .getRevWalk()
           .markUninteresting(receivePack.getRevWalk().parseCommit(targetRef.getObjectId()));
@@ -2329,7 +2326,7 @@
 
   private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
     if (!mergedParents.isEmpty()) {
-      Ref targetRef = allRefs().get(magicBranch.dest.get());
+      Ref targetRef = allRefs().get(magicBranch.dest.branch());
       if (targetRef != null) {
         RevWalk rw = receivePack.getRevWalk();
         RevCommit tip = rw.parseCommit(targetRef.getObjectId());
@@ -2349,7 +2346,10 @@
             rw.parseBody(c);
             messages.add(
                 new CommitValidationMessage(
-                    "Implicit Merge of " + c.abbreviate(7).name() + " " + c.getShortMessage(),
+                    "Implicit Merge of "
+                        + abbreviateName(c, rw.getObjectReader())
+                        + " "
+                        + c.getShortMessage(),
                     ValidationMessage.Type.ERROR));
           }
           reject(magicBranch.cmd, "implicit merges detected");
@@ -2425,7 +2425,7 @@
     private void setChangeId(int id) {
       possiblyOverrideWorkInProgress();
 
-      changeId = new Change.Id(id);
+      changeId = Change.id(id);
       ins =
           changeInserterFactory
               .create(changeId, commit, refName)
@@ -2519,7 +2519,7 @@
             });
         bu.addOp(changeId, new ChangeProgressOp(progress));
       } catch (Exception e) {
-        throw INSERT_EXCEPTION.apply(e);
+        throw asRestApiException(e);
       }
     }
   }
@@ -2778,10 +2778,10 @@
           addMessage(
               String.format(
                   "warning: no changes between prior commit %s and new commit %s",
-                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
+                  abbreviateName(priorCommit, reader), abbreviateName(newCommit, reader)));
         } else {
           StringBuilder msg = new StringBuilder();
-          msg.append("warning: ").append(reader.abbreviate(newCommit).name());
+          msg.append("warning: ").append(abbreviateName(newCommit, reader));
           msg.append(":");
           msg.append(" no files changed");
           if (!authorEq) {
@@ -2814,7 +2814,7 @@
       }
 
       if (edit.isPresent()) {
-        if (edit.get().getBasePatchSet().getId().equals(psId)) {
+        if (edit.get().getBasePatchSet().id().equals(psId)) {
           // replace edit
           cmd =
               new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
@@ -2903,12 +2903,12 @@
 
     private void addOps(BatchUpdate bu) {
       bu.addOp(
-          psId.getParentKey(),
+          psId.changeId(),
           new BatchUpdateOp() {
             @Override
             public boolean updateChange(ChangeContext ctx) {
               PatchSet ps = psUtil.get(ctx.getNotes(), psId);
-              List<String> oldGroups = ps.getGroups();
+              List<String> oldGroups = ps.groups();
               if (oldGroups == null) {
                 if (groups == null) {
                   return false;
@@ -2916,7 +2916,7 @@
               } else if (sameGroups(oldGroups, groups)) {
                 return false;
               }
-              psUtil.setGroups(ctx.getUpdate(psId), ps, groups);
+              ctx.getUpdate(psId).setGroups(groups);
               return true;
             }
           });
@@ -2998,7 +2998,7 @@
           PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
           if (psId != null) {
             refsById.put(obj, ref);
-            refsByChange.put(psId.getParentKey(), ref);
+            refsByChange.put(psId.changeId(), ref);
           }
         }
       }
@@ -3049,7 +3049,7 @@
     try {
       messages.addAll(refValidators.validateForRefOperation());
     } catch (RefOperationValidationException e) {
-      messages.addAll(Lists.newArrayList(e.getMessages()));
+      messages.addAll(e.getMessages());
       reject(cmd, e.getMessage());
       return false;
     }
@@ -3062,7 +3062,7 @@
    *
    * <p>On validation failure, the command is rejected.
    */
-  private void validateRegularPushCommits(Branch.NameKey branch, ReceiveCommand cmd)
+  private void validateRegularPushCommits(BranchNameKey branch, ReceiveCommand cmd)
       throws PermissionBackendException {
     if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
         && !(MagicBranch.isMagicBranch(cmd.getRefName())
@@ -3074,7 +3074,7 @@
       }
 
       Optional<AuthException> err =
-          checkRefPermission(permissions.ref(branch.get()), RefPermission.SKIP_VALIDATION);
+          checkRefPermission(permissions.ref(branch.branch()), RefPermission.SKIP_VALIDATION);
       if (err.isPresent()) {
         rejectProhibited(cmd, err.get());
         return;
@@ -3144,7 +3144,7 @@
               // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
               RevCommit newTip = rw.parseCommit(cmd.getNewId());
-              Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
+              BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName);
 
               rw.reset();
               rw.markStart(newTip);
@@ -3164,12 +3164,12 @@
 
                 for (Ref ref : byCommit.get(c.copy())) {
                   PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-                  Optional<ChangeNotes> notes = getChangeNotes(psId.getParentKey());
+                  Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
                   if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
                     existingPatchSets++;
                     bu.addOp(notes.get().getChangeId(), setPrivateOpFactory.create(false, null));
                     bu.addOp(
-                        psId.getParentKey(),
+                        psId.changeId(),
                         mergedByPushOpFactory.create(requestScopePropagator, psId, refName));
                     continue COMMIT;
                   }
@@ -3180,7 +3180,7 @@
                     byKey = executeIndexQuery(() -> openChangesByKeyByBranch(branch));
                   }
 
-                  ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
+                  ChangeNotes onto = byKey.get(Change.key(changeId.trim()));
                   if (onto != null) {
                     newPatchSets++;
                     // Hold onto this until we're done with the walk, as the call to
@@ -3256,7 +3256,7 @@
     }
   }
 
-  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch) {
+  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(BranchNameKey branch) {
     Map<Change.Key, ChangeNotes> r = new HashMap<>();
     for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
       try {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index 9aa21af..f7e0078 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -108,12 +108,11 @@
               .byProjectOpen(projectName)) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
-          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);
+          if (allPatchSets.contains(ps.commitId())) {
+            r.add(ps.commitId());
           }
         }
       }
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index b15035a..08c12ef 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -99,7 +99,7 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        Branch.NameKey dest,
+        BranchNameKey dest,
         boolean checkMergedInto,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
         @Assisted("priorCommitId") ObjectId priorCommit,
@@ -131,7 +131,7 @@
   private final ReviewerAdder reviewerAdder;
 
   private final ProjectState projectState;
-  private final Branch.NameKey dest;
+  private final BranchNameKey dest;
   private final boolean checkMergedInto;
   private final PatchSet.Id priorPatchSetId;
   private final ObjectId priorCommitId;
@@ -175,7 +175,7 @@
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       @Assisted ProjectState projectState,
-      @Assisted Branch.NameKey dest,
+      @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
       @Assisted("priorCommitId") ObjectId priorCommitId,
@@ -228,7 +228,7 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.get(), commit);
+      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto);
@@ -250,7 +250,7 @@
     }
     if (groups.isEmpty()) {
       PatchSet prevPs = psUtil.current(notes);
-      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.of();
+      groups = prevPs != null ? prevPs.groups() : ImmutableList.of();
     }
 
     ChangeData cd = changeDataFactory.create(ctx.getNotes());
@@ -454,7 +454,7 @@
           continue;
         }
 
-        LabelType lt = projectState.getLabelTypes().byLabel(a.getLabelId());
+        LabelType lt = projectState.getLabelTypes().byLabel(a.labelId());
         if (lt != null) {
           current.put(lt.getName(), a);
         }
@@ -477,9 +477,9 @@
 
     List<String> idList = commit.getFooterLines(CHANGE_ID);
     if (idList.isEmpty()) {
-      change.setKey(new Change.Key("I" + commitId.name()));
+      change.setKey(Change.key("I" + commitId.name()));
     } else {
-      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
+      change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
     }
   }
 
@@ -551,7 +551,7 @@
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::getAccountId))
+                        .map(PatchSetApproval::accountId))
                 .collect(toImmutableSet()));
         cm.addExtraCC(
             Streams.concat(
@@ -562,7 +562,7 @@
         cm.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.getId());
+            "Cannot send email for new patch set %s", newPatchSet.id());
       }
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index d42fbcf..42e3b82 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -127,15 +127,15 @@
 
     public CommitValidators forReceiveCommits(
         PermissionBackend.ForProject forProject,
-        Branch.NameKey branch,
+        BranchNameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         NoteMap rejectCommits,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
-      PermissionBackend.ForRef perm = forProject.ref(branch.get());
-      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
+      ProjectState projectState = projectCache.checkedGet(branch.project());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
@@ -161,21 +161,21 @@
 
     public CommitValidators forGerritCommits(
         PermissionBackend.ForProject forProject,
-        Branch.NameKey branch,
+        BranchNameKey branch,
         IdentifiedUser user,
         SshInfo sshInfo,
         RevWalk rw,
         @Nullable Change change)
         throws IOException {
-      PermissionBackend.ForRef perm = forProject.ref(branch.get());
-      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
+      ProjectState projectState = projectCache.checkedGet(branch.project());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
               new ProjectStateValidationListener(projectState),
               new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
               new AuthorUploaderValidator(user, perm, urlFormatter.get()),
-              new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.getParentKey())),
+              new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.project())),
               new ChangeIdValidator(
                   projectState,
                   user,
@@ -191,7 +191,7 @@
     }
 
     public CommitValidators forMergedCommits(
-        PermissionBackend.ForProject forProject, Branch.NameKey branch, IdentifiedUser user)
+        PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user)
         throws IOException {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
@@ -206,11 +206,11 @@
       //    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.
-      PermissionBackend.ForRef perm = forProject.ref(branch.get());
+      PermissionBackend.ForRef perm = forProject.ref(branch.branch());
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(perm),
-              new ProjectStateValidationListener(projectCache.checkedGet(branch.getParentKey())),
+              new ProjectStateValidationListener(projectCache.checkedGet(branch.project())),
               new AuthorUploaderValidator(user, perm, urlFormatter.get()),
               new CommitterUploaderValidator(user, perm, urlFormatter.get())));
     }
@@ -389,7 +389,7 @@
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
     private final ProjectConfig.Factory projectConfigFactory;
-    private final Branch.NameKey branch;
+    private final BranchNameKey branch;
     private final IdentifiedUser user;
     private final RevWalk rw;
     private final AllUsersName allUsers;
@@ -397,7 +397,7 @@
 
     public ConfigValidator(
         ProjectConfig.Factory projectConfigFactory,
-        Branch.NameKey branch,
+        BranchNameKey branch,
         IdentifiedUser user,
         RevWalk rw,
         AllUsersName allUsers,
@@ -413,7 +413,7 @@
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      if (REFS_CONFIG.equals(branch.get())) {
+      if (REFS_CONFIG.equals(branch.branch())) {
         List<CommitValidationMessage> messages = new ArrayList<>();
 
         try {
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index 6edd04e..ccb67d4 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git.validators;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -44,7 +44,7 @@
       Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException;
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index e7e021b..08950b7 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -80,7 +80,7 @@
       Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException {
@@ -156,11 +156,11 @@
         final Repository repo,
         final CodeReviewCommit commit,
         final ProjectState destProject,
-        final Branch.NameKey destBranch,
+        final BranchNameKey destBranch,
         final PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
-      if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
+      if (RefNames.REFS_CONFIG.equals(destBranch.branch())) {
         final Project.NameKey newParent;
         try {
           ProjectConfig cfg = projectConfigFactory.create(destProject.getNameKey());
@@ -251,7 +251,7 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
@@ -285,18 +285,17 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
-      Account.Id accountId = Account.Id.fromRef(destBranch.get());
+      Account.Id accountId = Account.Id.fromRef(destBranch.branch());
       if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
         return;
       }
 
       ChangeData cd =
-          changeDataFactory.create(
-              destProject.getProject().getNameKey(), patchSetId.getParentKey());
+          changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId());
       try {
         if (!cd.currentFilePaths().contains(AccountProperties.ACCOUNT_CONFIG)) {
           return;
@@ -336,13 +335,13 @@
         Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
-        Branch.NameKey destBranch,
+        BranchNameKey destBranch,
         PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
       // Groups are stored inside the 'All-Users' repository.
       if (!allUsersName.equals(destProject.getNameKey())
-          || !RefNames.isGroupRef(destBranch.get())) {
+          || !RefNames.isGroupRef(destBranch.branch())) {
         return;
       }
 
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
index 9eaf2d2..d27cc38 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
@@ -14,27 +14,28 @@
 
 package com.google.gerrit.server.git.validators;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
 
 public class RefOperationValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final Iterable<ValidationMessage> messages;
+  private final ImmutableList<ValidationMessage> messages;
 
-  public RefOperationValidationException(String reason, Iterable<ValidationMessage> messages) {
+  public RefOperationValidationException(String reason, ImmutableList<ValidationMessage> messages) {
     super(reason);
     this.messages = messages;
   }
 
-  public Iterable<ValidationMessage> getMessages() {
+  public ImmutableList<ValidationMessage> getMessages() {
     return messages;
   }
 
   @Override
   public String getMessage() {
-    StringBuilder msg = new StringBuilder(super.getMessage());
-    for (ValidationMessage error : messages) {
-      msg.append("\n").append(error.getMessage());
-    }
-    return msg.toString();
+    return messages.stream()
+        .map(ValidationMessage::getMessage)
+        .collect(joining("\n", super.getMessage() + "\n", ""));
   }
 }
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index dd5d508..919bd5a 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -13,9 +13,9 @@
 // limitations under the License.
 package com.google.gerrit.server.git.validators;
 
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -39,8 +39,6 @@
 public class RefOperationValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final GetErrorMessages GET_ERRORS = new GetErrorMessages();
-
   public interface Factory {
     RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
   }
@@ -93,22 +91,15 @@
     return messages;
   }
 
-  private void throwException(Iterable<ValidationMessage> messages, RefReceivedEvent event)
+  private void throwException(List<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());
     logger.atSevere().log(header);
-    throw new RefOperationValidationException(header, errors);
-  }
-
-  private static class GetErrorMessages implements Predicate<ValidationMessage> {
-    @Override
-    public boolean apply(ValidationMessage input) {
-      return input.isError();
-    }
+    throw new RefOperationValidationException(
+        header, messages.stream().filter(ValidationMessage::isError).collect(toImmutableList()));
   }
 
   private static class DisallowCreationAndDeletionOfGerritMaintainedBranches
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 5fe3e8e..fab5b9e 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -81,7 +81,7 @@
    * @return the group, null if no group is found for the given group ID
    */
   public GroupDescription.Basic parseId(String id) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(id);
     if (groupBackend.handles(uuid)) {
       GroupDescription.Basic d = groupBackend.get(uuid);
       if (d != null) {
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 85c1e73..75ce0de 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -56,19 +56,19 @@
 
   /** Common UUID assigned to the "Anonymous Users" group. */
   public static final AccountGroup.UUID ANONYMOUS_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
 
   /** Common UUID assigned to the "Registered Users" group. */
   public static final AccountGroup.UUID REGISTERED_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Registered-Users");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Registered-Users");
 
   /** Common UUID assigned to the "Project Owners" placeholder group. */
   public static final AccountGroup.UUID PROJECT_OWNERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Project-Owners");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Project-Owners");
 
   /** Common UUID assigned to the "Change Owner" placeholder group. */
   public static final AccountGroup.UUID CHANGE_OWNER =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Change-Owner");
+      AccountGroup.uuid(SYSTEM_GROUP_SCHEME + "Change-Owner");
 
   private static final AccountGroup.UUID[] all = {
     ANONYMOUS_USERS, REGISTERED_USERS, PROJECT_OWNERS, CHANGE_OWNER,
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index 106ee6b..fb58577 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.group.db;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
@@ -21,7 +23,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
@@ -69,66 +71,75 @@
 
   private ImmutableList<AccountGroupMemberAudit> getMembersAudit(
       AccountGroup.Id groupId, List<ParsedCommit> commits) {
-    ListMultimap<MemberKey, AccountGroupMemberAudit> audits =
+    ListMultimap<MemberKey, AccountGroupMemberAudit.Builder> audits =
         MultimapBuilder.hashKeys().linkedListValues().build();
-    ImmutableList.Builder<AccountGroupMemberAudit> result = ImmutableList.builder();
+    List<AccountGroupMemberAudit.Builder> result = new ArrayList<>();
     for (ParsedCommit pc : commits) {
       for (Account.Id id : pc.addedMembers()) {
         MemberKey key = MemberKey.create(groupId, id);
-        AccountGroupMemberAudit audit =
-            new AccountGroupMemberAudit(
-                new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
+        AccountGroupMemberAudit.Builder audit =
+            AccountGroupMemberAudit.builder()
+                .memberId(id)
+                .groupId(groupId)
+                .addedOn(pc.when())
+                .addedBy(pc.authorId());
         audits.put(key, audit);
         result.add(audit);
       }
       for (Account.Id id : pc.removedMembers()) {
-        List<AccountGroupMemberAudit> adds = audits.get(MemberKey.create(groupId, id));
+        List<AccountGroupMemberAudit.Builder> adds = audits.get(MemberKey.create(groupId, id));
         if (!adds.isEmpty()) {
-          AccountGroupMemberAudit audit = adds.remove(0);
+          AccountGroupMemberAudit.Builder audit = adds.remove(0);
           audit.removed(pc.authorId(), pc.when());
         } else {
           // Match old behavior of DbGroupAuditListener and add a "legacy" add/remove pair.
-          AccountGroupMemberAudit audit =
-              new AccountGroupMemberAudit(
-                  new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
-          audit.removedLegacy();
+          AccountGroupMemberAudit.Builder audit =
+              AccountGroupMemberAudit.builder()
+                  .groupId(groupId)
+                  .memberId(id)
+                  .addedOn(pc.when())
+                  .addedBy(pc.authorId())
+                  .removedLegacy();
           result.add(audit);
         }
       }
     }
-    return result.build();
+    return result.stream().map(AccountGroupMemberAudit.Builder::build).collect(toImmutableList());
   }
 
-  public ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+  public ImmutableList<AccountGroupByIdAudit> getSubgroupsAudit(
       Repository repo, AccountGroup.UUID uuid) throws IOException, ConfigInvalidException {
     return getSubgroupsAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
   }
 
-  private ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+  private ImmutableList<AccountGroupByIdAudit> getSubgroupsAudit(
       AccountGroup.Id groupId, List<ParsedCommit> commits) {
-    ListMultimap<SubgroupKey, AccountGroupByIdAud> audits =
+    ListMultimap<SubgroupKey, AccountGroupByIdAudit.Builder> audits =
         MultimapBuilder.hashKeys().linkedListValues().build();
-    ImmutableList.Builder<AccountGroupByIdAud> result = ImmutableList.builder();
+    List<AccountGroupByIdAudit.Builder> result = new ArrayList<>();
     for (ParsedCommit pc : commits) {
       for (AccountGroup.UUID uuid : pc.addedSubgroups()) {
         SubgroupKey key = SubgroupKey.create(groupId, uuid);
-        AccountGroupByIdAud audit =
-            new AccountGroupByIdAud(
-                new AccountGroupByIdAud.Key(groupId, uuid, pc.when()), pc.authorId());
+        AccountGroupByIdAudit.Builder audit =
+            AccountGroupByIdAudit.builder()
+                .groupId(groupId)
+                .includeUuid(uuid)
+                .addedOn(pc.when())
+                .addedBy(pc.authorId());
         audits.put(key, audit);
         result.add(audit);
       }
       for (AccountGroup.UUID uuid : pc.removedSubgroups()) {
-        List<AccountGroupByIdAud> adds = audits.get(SubgroupKey.create(groupId, uuid));
+        List<AccountGroupByIdAudit.Builder> adds = audits.get(SubgroupKey.create(groupId, uuid));
         if (!adds.isEmpty()) {
-          AccountGroupByIdAud audit = adds.remove(0);
+          AccountGroupByIdAudit.Builder audit = adds.remove(0);
           audit.removed(pc.authorId(), pc.when());
         } else {
           // Unlike members, DbGroupAuditListener didn't insert an add/remove pair here.
         }
       }
     }
-    return result.build();
+    return result.stream().map(AccountGroupByIdAudit.Builder::build).collect(toImmutableList());
   }
 
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
@@ -182,7 +193,7 @@
       logInvalid(uuid, c, line);
       return Optional.empty();
     }
-    return Optional.of(new AccountGroup.UUID(ident.getEmailAddress()));
+    return Optional.of(AccountGroup.uuid(ident.getEmailAddress()));
   }
 
   private static void logInvalid(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 903b9c0..2c9a851 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -411,12 +411,12 @@
   }
 
   private ImmutableSet<Account.Id> readMembers() throws IOException, ConfigInvalidException {
-    return readFromFile(MEMBERS_FILE, entry -> new Account.Id(Integer.parseInt(entry)));
+    return readFromFile(MEMBERS_FILE, entry -> Account.id(Integer.parseInt(entry)));
   }
 
   private ImmutableSet<AccountGroup.UUID> readSubgroups()
       throws IOException, ConfigInvalidException {
-    return readFromFile(SUBGROUPS_FILE, AccountGroup.UUID::new);
+    return readFromFile(SUBGROUPS_FILE, AccountGroup::uuid);
   }
 
   private <E> ImmutableSet<E> readFromFile(String filePath, Function<String, E> fromStringFunction)
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index f7a104d..d684436 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -45,7 +45,7 @@
             String.format(
                 "ID of the group %s must not be negative, found %d", groupUuid.get(), id));
       }
-      group.setId(new AccountGroup.Id(id));
+      group.setId(AccountGroup.id(id));
     }
 
     @Override
@@ -77,7 +77,7 @@
       // the NoteDb migration converted such groups faithfully, so we need to be able to read them
       // back here.
       name = Strings.nullToEmpty(name);
-      group.setNameKey(new AccountGroup.NameKey(name));
+      group.setNameKey(AccountGroup.nameKey(name));
     }
 
     @Override
@@ -135,7 +135,7 @@
         throw new ConfigInvalidException(
             String.format("Owner UUID of the group %s must be defined", groupUuid.get()));
       }
-      group.setOwnerGroupUUID(new AccountGroup.UUID(ownerGroupUuid));
+      group.setOwnerGroupUUID(AccountGroup.uuid(ownerGroupUuid));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index eda7153..ff540a8 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -266,7 +267,7 @@
       RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null;
 
       for (Map.Entry<AccountGroup.UUID, String> e : biMap.entrySet()) {
-        AccountGroup.NameKey nameKey = new AccountGroup.NameKey(e.getValue());
+        AccountGroup.NameKey nameKey = AccountGroup.nameKey(e.getValue());
         ObjectId noteKey = getNoteKey(nameKey);
         noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter);
       }
@@ -286,7 +287,7 @@
       cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : ""));
       ObjectId newId = inserter.insert(cb).copy();
 
-      ObjectId oldId = oldCommit != null ? oldCommit.copy() : ObjectId.zeroId();
+      ObjectId oldId = ObjectIds.copyOrZero(oldCommit);
       bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES));
     }
   }
@@ -442,7 +443,7 @@
       throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
     }
 
-    return new GroupReference(new AccountGroup.UUID(uuid), name);
+    return new GroupReference(AccountGroup.uuid(uuid), name);
   }
 
   private String getCommitMessage() {
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 37de011..1c8d897 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -152,7 +152,7 @@
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
    */
-  public List<AccountGroupByIdAud> getSubgroupsAudit(Repository repo, AccountGroup.UUID groupUuid)
+  public List<AccountGroupByIdAudit> getSubgroupsAudit(Repository repo, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
     return auditLogReader.getSubgroupsAudit(repo, groupUuid);
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index c3ca60b..cea8101 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -163,7 +163,7 @@
           continue;
         }
 
-        ObjectId nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(gRef.getName()));
+        ObjectId nameKey = GroupNameNotes.getNoteKey(AccountGroup.nameKey(gRef.getName()));
         if (!Objects.equals(nameKey, note)) {
           result.problems.add(
               error("notename entry %s does not match name %s", note, gRef.getName()));
diff --git a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
index 2f91394..f5e8fca 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -38,73 +38,65 @@
     return InternalGroupSubject::new;
   }
 
-  private InternalGroupSubject(FailureMetadata metadata, InternalGroup actual) {
-    super(metadata, actual);
+  private final InternalGroup group;
+
+  private InternalGroupSubject(FailureMetadata metadata, InternalGroup group) {
+    super(metadata, group);
+    this.group = group;
   }
 
   public ComparableSubject<?, AccountGroup.UUID> groupUuid() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("groupUuid()").that(group.getGroupUUID());
+    return check("getGroupUUID()").that(group.getGroupUUID());
   }
 
   public ComparableSubject<?, AccountGroup.NameKey> nameKey() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("nameKey()").that(group.getNameKey());
+    return check("getNameKey()").that(group.getNameKey());
   }
 
   public StringSubject name() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("name()").that(group.getName());
+    return check("getName()").that(group.getName());
   }
 
   public Subject<DefaultSubject, Object> id() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("id()").that(group.getId());
+    return check("getId()").that(group.getId());
   }
 
   public StringSubject description() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("description()").that(group.getDescription());
+    return check("getDescription()").that(group.getDescription());
   }
 
   public ComparableSubject<?, AccountGroup.UUID> ownerGroupUuid() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("ownerGroupUuid()").that(group.getOwnerGroupUUID());
+    return check("getOwnerGroupUUID()").that(group.getOwnerGroupUUID());
   }
 
   public BooleanSubject visibleToAll() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("visibleToAll()").that(group.isVisibleToAll());
+    return check("isVisibleToAll()").that(group.isVisibleToAll());
   }
 
   public ComparableSubject<?, Timestamp> createdOn() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("createdOn()").that(group.getCreatedOn());
+    return check("getCreatedOn()").that(group.getCreatedOn());
   }
 
   public IterableSubject members() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("members()").that(group.getMembers());
+    return check("getMembers()").that(group.getMembers());
   }
 
   public IterableSubject subgroups() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("subgroups()").that(group.getSubgroups());
+    return check("getSubgroups()").that(group.getSubgroups());
   }
 
   public ComparableSubject<?, ObjectId> refState() {
     isNotNull();
-    InternalGroup group = actual();
-    return check("refState()").that(group.getRefState());
+    return check("getRefState()").that(group.getRefState());
   }
 }
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 550b15c..fa36ead 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -43,7 +43,7 @@
    */
   public GroupDescription.Basic create(String name) {
     requireNonNull(name);
-    return create(new AccountGroup.UUID(name.startsWith(PREFIX) ? name : PREFIX + name));
+    return create(AccountGroup.uuid(name.startsWith(PREFIX) ? name : PREFIX + name));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index f67a41d..ab3a96d 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.util.stream.Collectors.toSet;
 
-import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -36,6 +35,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -96,7 +96,7 @@
                   FluentIterable.from(a.getExternalIds())
                       .transform(ExternalId::email)
                       .append(Collections.singleton(a.getAccount().getPreferredEmail()))
-                      .filter(Predicates.notNull())
+                      .filter(Objects::nonNull)
                       .transform(String::toLowerCase)
                       .toSet());
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 394761b..599c604 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
 import static com.google.gerrit.index.FieldDef.intRange;
@@ -51,7 +52,6 @@
 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.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -128,7 +128,7 @@
 
   /** Reference (aka branch) the change will submit onto. */
   public static final FieldDef<ChangeData, String> REF =
-      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get()));
+      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().branch()));
 
   /** Topic, a short annotation on the branch. */
   public static final FieldDef<ChangeData, String> EXACT_TOPIC =
@@ -450,14 +450,8 @@
   public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
       exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
 
-  private static Set<String> getRevisions(ChangeData cd) {
-    Set<String> revisions = new HashSet<>();
-    for (PatchSet ps : cd.patchSets()) {
-      if (ps.getRevision() != null) {
-        revisions.add(ps.getRevision().get());
-      }
-    }
-    return revisions;
+  private static ImmutableSet<String> getRevisions(ChangeData cd) {
+    return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
   }
 
   /** Tracking id extracted from a footer. */
@@ -473,13 +467,12 @@
     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));
+      if (a.value() != 0 && !a.isLegacySubmit()) {
+        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        if (owners && cd.change().getOwner().equals(a.accountId())) {
+          allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
         }
-        distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
+        distinctApprovals.add(formatLabel(a.label(), a.value()));
       }
     }
     allApprovals.addAll(distinctApprovals);
@@ -669,8 +662,7 @@
   public static final FieldDef<ChangeData, Iterable<String>> GROUP =
       exact(ChangeQueryBuilder.FIELD_GROUP)
           .buildRepeatable(
-              cd ->
-                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
+              cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
 
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
@@ -776,7 +768,7 @@
           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;
+          srl.appliedBy = label.appliedBy != null ? Account.id(label.appliedBy) : null;
           rec.labels.add(srl);
         }
       }
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 4062988..32d63fc 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -23,7 +23,7 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -149,11 +149,11 @@
     @Override
     protected List<Change> impl(RequestContext ctx) {
       String ref = event.getRefName();
-      Project.NameKey project = new Project.NameKey(event.getProjectName());
+      Project.NameKey project = Project.nameKey(event.getProjectName());
       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(BranchNameKey.create(project, ref)));
     }
 
     @Override
@@ -178,7 +178,7 @@
       // Reload change, as some time may have passed since GetChanges.
       try {
         Change c =
-            notesFactory.createChecked(new Project.NameKey(event.getProjectName()), id).getChange();
+            notesFactory.createChecked(Project.nameKey(event.getProjectName()), id).getChange();
         indexerFactory.create(executor, indexes).index(c);
       } catch (NoSuchChangeException e) {
         indexerFactory.create(executor, indexes).delete(id);
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 338cf3d..fc5320c 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -131,8 +131,7 @@
       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)));
+      result.put(Project.nameKey(Url.decode(parts.get(0))), RefStatePattern.create(parts.get(1)));
     }
     return result;
   }
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index 29e3867..83c1625 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -23,13 +23,13 @@
 import static com.google.gerrit.index.FieldDef.timestamp;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for groups. */
@@ -82,7 +82,7 @@
       storedOnly("ref_state")
           .build(
               g -> {
-                byte[] a = new byte[Constants.OBJECT_ID_STRING_LENGTH];
+                byte[] a = new byte[ObjectIds.STR_LEN];
                 MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
                 return a;
               });
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index b1acab9..3655369 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -211,7 +211,7 @@
       throws UpdateException, RestApiException {
     try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
       List<ChangeData> changeDataList =
-          queryProvider.get().byLegacyChangeId(new Change.Id(metadata.changeNumber));
+          queryProvider.get().byLegacyChangeId(Change.id(metadata.changeNumber));
       if (changeDataList.size() != 1) {
         logger.atSevere().log(
             "Message %s references unique change %s,"
@@ -259,7 +259,7 @@
         return;
       }
 
-      Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet), parsedComments, message.id());
+      Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
       BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.nowTs());
       batchUpdate.addOp(cd.getId(), o);
       batchUpdate.execute();
@@ -330,7 +330,7 @@
       approvalsUtil
           .byPatchSetUser(
               notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
-          .forEach(a -> approvals.put(a.getLabel(), a.getValue()));
+          .forEach(a -> approvals.put(a.label(), a.value()));
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
       commentAdded.fire(
@@ -362,7 +362,7 @@
       if (mailComment.getInReplyTo() != null) {
         return psUtil.get(
             ctx.getNotes(),
-            new PatchSet.Id(ctx.getChange().getId(), mailComment.getInReplyTo().key.patchSetId));
+            PatchSet.id(ctx.getChange().getId(), mailComment.getInReplyTo().key.patchSetId));
       }
       return current;
     }
@@ -386,7 +386,7 @@
           commentsUtil.newComment(
               ctx,
               fileName,
-              patchSetForComment.getId(),
+              patchSetForComment.id(),
               (short) side.ordinal(),
               mailComment.getMessage(),
               false,
@@ -399,7 +399,7 @@
         comment.range = mailComment.getInReplyTo().range;
         comment.unresolved = mailComment.getInReplyTo().unresolved;
       }
-      CommentsUtil.setCommentRevId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+      CommentsUtil.setCommentCommitId(comment, patchListCache, ctx.getChange(), patchSetForComment);
       return comment;
     }
   }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 4905823..f28afbd 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -156,10 +156,10 @@
     }
 
     if (patchSet != null) {
-      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.getPatchSetId() + "");
+      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
       if (patchSetInfo == null) {
         try {
-          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.getId());
+          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
         } catch (PatchSetInfoNotAvailableException | StorageException err) {
           patchSetInfo = null;
         }
@@ -204,11 +204,8 @@
   }
 
   private void setCommitIdHeader() {
-    if (patchSet != null
-        && patchSet.getRevision() != null
-        && patchSet.getRevision().get() != null
-        && patchSet.getRevision().get().length() > 0) {
-      setHeader(MailHeader.COMMIT.fieldName(), patchSet.getRevision().get());
+    if (patchSet != null) {
+      setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
     }
   }
 
@@ -289,11 +286,11 @@
   /** Get the patch list corresponding to patch set patchSetId of this change. */
   protected PatchList getPatchList(int patchSetId) throws PatchListNotAvailableException {
     PatchSet ps;
-    if (patchSetId == patchSet.getPatchSetId()) {
+    if (patchSetId == patchSet.number()) {
       ps = patchSet;
     } else {
       try {
-        ps = args.patchSetUtil.get(changeData.notes(), new PatchSet.Id(change.getId(), patchSetId));
+        ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
       } catch (StorageException e) {
         throw new PatchListNotAvailableException("Failed to get patchSet");
       }
@@ -348,7 +345,7 @@
       return new Watchers();
     }
 
-    ProjectWatch watch = new ProjectWatch(args, branch.getParentKey(), projectState, changeData);
+    ProjectWatch watch = new ProjectWatch(args, branch.project(), projectState, changeData);
     return watch.getWatchers(type, includeWatchersFromNotifyConfig);
   }
 
@@ -414,7 +411,7 @@
       case ALL:
       default:
         if (patchSet != null) {
-          authors.add(patchSet.getUploader());
+          authors.add(patchSet.uploader());
         }
         if (patchSetInfo != null) {
           if (patchSetInfo.getAuthor().getAccount() != null) {
@@ -464,8 +461,8 @@
     soyContext.put("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
-    patchSetData.put("patchSetId", patchSet.getPatchSetId());
-    patchSetData.put("refName", patchSet.getRefName());
+    patchSetData.put("patchSetId", patchSet.number());
+    patchSetData.put("refName", patchSet.refName());
     soyContext.put("patchSet", patchSetData);
 
     Map<String, Object> patchSetInfoData = new HashMap<>();
@@ -475,7 +472,7 @@
 
     footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
     footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
-    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.getPatchSetId());
+    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     if (change.getAssignee() != null) {
       footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 01d8a17..5b25ebe1 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -29,6 +29,7 @@
 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.KeyUtil;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RobotComment;
@@ -41,7 +42,6 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cbc4117..4a4a732 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -69,15 +69,15 @@
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
       for (PatchSetApproval ca :
-          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.getId(), null, null)) {
-        LabelType lt = labelTypes.byLabel(ca.getLabelId());
+          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id(), null, null)) {
+        LabelType lt = labelTypes.byLabel(ca.labelId());
         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);
+        if (ca.value() > 0) {
+          pos.put(ca.accountId(), lt.getName(), ca);
+        } else if (ca.value() < 0) {
+          neg.put(ca.accountId(), lt.getName(), ca);
         }
       }
 
@@ -117,7 +117,7 @@
         } else {
           txt.append(lt.getName());
           txt.append('=');
-          txt.append(LabelValue.formatValue(ca.getValue()));
+          txt.append(LabelValue.formatValue(ca.value()));
         }
       }
       txt.append('\n');
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 64047bf..10d6ba5 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import java.util.HashMap;
@@ -33,9 +33,9 @@
 public abstract class NotificationEmail extends OutgoingEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected Branch.NameKey branch;
+  protected BranchNameKey branch;
 
-  protected NotificationEmail(EmailArguments ea, String mc, Branch.NameKey branch) {
+  protected NotificationEmail(EmailArguments ea, String mc, BranchNameKey branch) {
     super(ea, mc);
     this.branch = branch;
   }
@@ -50,7 +50,7 @@
     // Set a reasonable list id so that filters can be used to sort messages
     setHeader(
         "List-Id",
-        "<gerrit-" + branch.getParentKey().get().replace('/', '-') + "." + getGerritHost() + ">");
+        "<gerrit-" + branch.project().get().replace('/', '-') + "." + getGerritHost() + ">");
     if (getSettingsUrl() != null) {
       setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
     }
@@ -104,7 +104,7 @@
   protected void setupSoyContext() {
     super.setupSoyContext();
 
-    String projectName = branch.getParentKey().get();
+    String projectName = branch.project().get();
     soyContext.put("projectName", projectName);
     // shortProjectName is the project name with the path abbreviated.
     soyContext.put("shortProjectName", getShortProjectName(projectName));
@@ -118,11 +118,11 @@
     soyContextEmailData.put("sshHost", getSshHost());
 
     Map<String, String> branchData = new HashMap<>();
-    branchData.put("shortName", branch.getShortName());
+    branchData.put("shortName", branch.shortName());
     soyContext.put("branch", branchData);
 
-    footers.add(MailHeader.PROJECT.withDelimiter() + branch.getParentKey().get());
-    footers.add("Gerrit-Branch: " + branch.getShortName());
+    footers.add(MailHeader.PROJECT.withDelimiter() + branch.project().get());
+    footers.add("Gerrit-Branch: " + branch.shortName());
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 91469f8..f0314c6 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -147,7 +147,7 @@
   }
 
   public void setPatchSetId(PatchSet.Id psId) {
-    checkArgument(psId == null || psId.getParentKey().equals(getId()));
+    checkArgument(psId == null || psId.changeId().equals(getId()));
     this.psId = psId;
   }
 
@@ -185,6 +185,14 @@
   protected abstract String getRefName();
 
   /**
+   * Whether to allow bypassing the check that an update does not exceed the max update count on an
+   * object.
+   */
+  protected boolean bypassMaxUpdates() {
+    return false;
+  }
+
+  /**
    * Apply this update to the given inserter.
    *
    * @param rw walk for reading back any objects needed for the update.
@@ -263,7 +271,7 @@
   }
 
   protected void verifyComment(Comment c) {
-    checkArgument(c.revId != null, "RevId required for comment: %s", c);
+    checkArgument(c.getCommitId() != null, "commit ID 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",
diff --git a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
new file mode 100644
index 0000000..5d909d0
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.checkState;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.FanOutExecutor;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.transport.PushCertificate;
+
+/**
+ * Performs an update on {@code All-Users} asynchronously if required. No-op in case no updates were
+ * scheduled for asynchronous execution.
+ */
+public class AllUsersAsyncUpdate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ExecutorService executor;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager repoManager;
+  private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+
+  private PersonIdent serverIdent;
+
+  @Inject
+  AllUsersAsyncUpdate(
+      @FanOutExecutor ExecutorService executor,
+      AllUsersName allUsersName,
+      GitRepositoryManager repoManager) {
+    this.executor = executor;
+    this.allUsersName = allUsersName;
+    this.repoManager = repoManager;
+    this.draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
+  }
+
+  void setDraftUpdates(ListMultimap<String, ChangeDraftUpdate> draftUpdates) {
+    checkState(isEmpty(), "attempted to set draft comment updates for async execution twice");
+    boolean allPublishOnly =
+        draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+    checkState(allPublishOnly, "not all updates can be run asynchronously");
+    // Add deep copies to avoid any threading issues.
+    for (Map.Entry<String, ChangeDraftUpdate> entry : draftUpdates.entries()) {
+      this.draftUpdates.put(entry.getKey(), entry.getValue().copy());
+    }
+    if (draftUpdates.size() > 0) {
+      // Save the PersonIdent for later so that we get consistent time stamps in the commit and ref
+      // log.
+      serverIdent = Iterables.get(draftUpdates.entries(), 0).getValue().serverIdent;
+    }
+  }
+
+  /** Returns true if no operations should be performed on the repo. */
+  boolean isEmpty() {
+    return draftUpdates.isEmpty();
+  }
+
+  /** Executes repository update asynchronously. No-op in case no updates were scheduled. */
+  void execute(PersonIdent refLogIdent, String refLogMessage, PushCertificate pushCert) {
+    if (isEmpty()) {
+      return;
+    }
+
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(
+            () -> {
+              try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
+                allUsersRepo.addUpdates(draftUpdates);
+                allUsersRepo.flush();
+                BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
+                bru.setPushCertificate(pushCert);
+                if (refLogMessage != null) {
+                  bru.setRefLogMessage(refLogMessage, false);
+                } else {
+                  bru.setRefLogMessage(
+                      firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs async"),
+                      false);
+                }
+                bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent);
+                bru.setAtomic(true);
+                allUsersRepo.cmds.addTo(bru);
+                bru.setAllowNonFastForwards(true);
+                RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
+              } catch (IOException e) {
+                logger.atSevere().withCause(e).log(
+                    "Failed to delete draft comments asynchronously after publishing them");
+              }
+            });
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 38b7b12..bf27019 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
@@ -26,7 +27,6 @@
 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.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.inject.assistedinject.Assisted;
@@ -35,7 +35,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
-import java.util.HashSet;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -74,19 +74,25 @@
 
   @AutoValue
   abstract static class Key {
-    abstract String revId();
+    abstract ObjectId commitId();
 
     abstract Comment.Key key();
   }
 
+  enum DeleteReason {
+    DELETED,
+    PUBLISHED,
+    FIXED
+  }
+
   private static Key key(Comment c) {
-    return new AutoValue_ChangeDraftUpdate_Key(c.revId, c.key);
+    return new AutoValue_ChangeDraftUpdate_Key(c.getCommitId(), c.key);
   }
 
   private final AllUsersName draftsProject;
 
   private List<Comment> put = new ArrayList<>();
-  private Set<Key> delete = new HashSet<>();
+  private Map<Key, DeleteReason> delete = new HashMap<>();
 
   @AssistedInject
   private ChangeDraftUpdate(
@@ -117,41 +123,93 @@
   }
 
   public void putComment(Comment c) {
+    checkState(!put.contains(c), "comment already added");
     verifyComment(c);
     put.add(c);
   }
 
-  public void deleteComment(Comment c) {
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user published it.
+   */
+  public void markCommentPublished(Comment c) {
+    checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
-    delete.add(key(c));
+    delete.put(key(c), DeleteReason.PUBLISHED);
   }
 
-  public void deleteComment(String revId, Comment.Key key) {
-    delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
+   */
+  public void deleteComment(Comment c) {
+    checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
+    verifyComment(c);
+    delete.put(key(c), DeleteReason.DELETED);
+  }
+
+  /**
+   * Marks a comment for deletion. Called when the comment should have been deleted previously, but
+   * wasn't, so we're fixing it up.
+   */
+  public void deleteComment(ObjectId commitId, Comment.Key key) {
+    Key commentKey = new AutoValue_ChangeDraftUpdate_Key(commitId, key);
+    checkState(!delete.containsKey(commentKey), "comment already marked for deletion");
+    delete.put(commentKey, DeleteReason.FIXED);
+  }
+
+  /**
+   * Returns true if all we do in this operations is deletes caused by publishing or fixing up
+   * comments.
+   */
+  public boolean canRunAsync() {
+    return put.isEmpty()
+        && delete.values().stream()
+            .allMatch(r -> r == DeleteReason.PUBLISHED || r == DeleteReason.FIXED);
+  }
+
+  /**
+   * Returns a copy of the current {@link ChangeDraftUpdate} that contains references to all
+   * deletions. Copying of {@link ChangeDraftUpdate} is only allowed if it contains no new comments.
+   */
+  ChangeDraftUpdate copy() {
+    checkState(
+        put.isEmpty(),
+        "copying ChangeDraftUpdate is allowed only if it doesn't contain new comments");
+    ChangeDraftUpdate clonedUpdate =
+        new ChangeDraftUpdate(
+            authorIdent,
+            draftsProject,
+            noteUtil,
+            new Change(getChange()),
+            accountId,
+            realAccountId,
+            authorIdent,
+            when);
+    clonedUpdate.delete.putAll(delete);
+    return clonedUpdate;
   }
 
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    Set<ObjectId> updatedCommits = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
     for (Comment c : put) {
-      if (!delete.contains(key(c))) {
-        cache.get(new RevId(c.revId)).putComment(c);
+      if (!delete.keySet().contains(key(c))) {
+        cache.get(c.getCommitId()).putComment(c);
       }
     }
-    for (Key k : delete) {
-      cache.get(new RevId(k.revId())).deleteComment(k.key());
+    for (Key k : delete.keySet()) {
+      cache.get(k.commitId()).deleteComment(k.key());
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, 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());
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedCommits.add(e.getKey());
+      ObjectId id = e.getKey();
       byte[] data = e.getValue().build(noteUtil.getChangeNoteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
@@ -174,7 +232,7 @@
 
     // 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());
+    boolean touchedAllRevs = updatedCommits.equals(rnm.revisionNotes.keySet());
     if (touchedAllRevs && !hasComments) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 764d41d..e1217c2 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -38,7 +38,7 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -46,7 +46,6 @@
 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.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -63,7 +62,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Predicate;
@@ -78,7 +76,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
-      Ordering.from(comparing(PatchSetApproval::getGranted));
+      Ordering.from(comparing(PatchSetApproval::granted));
 
   public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
       Ordering.from(comparing(ChangeMessage::getWrittenOn));
@@ -127,7 +125,7 @@
 
     public static Change newChange(Project.NameKey project, Change.Id changeId) {
       return new Change(
-          null, changeId, null, new Branch.NameKey(project, "INVALID_NOTE_DB_ONLY"), null);
+          null, changeId, null, BranchNameKey.create(project, "INVALID_NOTE_DB_ONLY"), null);
     }
 
     public ChangeNotes create(Project.NameKey project, Change.Id changeId) {
@@ -331,9 +329,7 @@
     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()));
-      }
+      b.putAll(state.patchSets());
       patchSets = b.build();
     }
     return patchSets;
@@ -341,12 +337,7 @@
 
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
     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();
+      approvals = ImmutableListMultimap.copyOf(state.approvals());
     }
     return approvals;
   }
@@ -403,7 +394,7 @@
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<RevId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, Comment> getComments() {
     return state.publishedComments();
   }
 
@@ -418,11 +409,15 @@
     return commentKeys;
   }
 
-  public ImmutableListMultimap<RevId, Comment> getDraftComments(Account.Id author) {
+  public int getUpdateCount() {
+    return state.updateCount();
+  }
+
+  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
 
-  public ImmutableListMultimap<RevId, Comment> getDraftComments(
+  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(
       Account.Id author, @Nullable Ref ref) {
     loadDraftComments(author, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
@@ -433,7 +428,7 @@
             draftCommentNotes.getComments(), e -> !getCommentKeys().contains(e.getValue().key)));
   }
 
-  public ImmutableListMultimap<RevId, RobotComment> getRobotComments() {
+  public ImmutableListMultimap<ObjectId, RobotComment> getRobotComments() {
     loadRobotComments();
     return robotCommentNotes.getComments();
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index e2af855..517898a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -98,8 +98,8 @@
       public Key deserialize(byte[] in) {
         ChangeNotesKeyProto proto = Protos.parseUnchecked(ChangeNotesKeyProto.parser(), in);
         return Key.create(
-            new Project.NameKey(proto.getProject()),
-            new Change.Id(proto.getChangeId()),
+            Project.nameKey(proto.getProject()),
+            Change.id(proto.getChangeId()),
             ObjectIdConverter.create().fromByteString(proto.getId()));
       }
     }
@@ -112,8 +112,11 @@
     // Single pointer overhead.
     private static final int P = 8;
 
+    // Single int overhead.
+    private static final int I = 4;
+
     // Single IntKey overhead.
-    private static final int K = O + 4;
+    private static final int K = O + I;
 
     // Single Timestamp overhead.
     private static final int T = O + 8;
@@ -173,7 +176,8 @@
           + map(state.publishedComments().asMap(), comment())
           + 1 // isPrivate
           + 1 // workInProgress
-          + 1; // reviewStarted
+          + 1 // reviewStarted
+          + I; // updateCount
     }
 
     private static int ptr(Object o, int size) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 7ba5679..185f651 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -40,7 +40,6 @@
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
@@ -48,6 +47,7 @@
 import com.google.common.collect.ImmutableTable;
 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.common.collect.Sets;
 import com.google.common.collect.Table;
@@ -67,7 +67,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -101,23 +100,6 @@
 class ChangeNotesParser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  // 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");
-
-  @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 ChangeNoteJson changeNoteJson;
   private final LegacyChangeNoteRead legacyChangeNoteRead;
@@ -134,13 +116,13 @@
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final ListMultimap<RevId, Comment> comments;
-  private final Map<PatchSet.Id, PatchSet> patchSets;
+  private final ListMultimap<ObjectId, Comment> comments;
+  private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
   private final List<PatchSet.Id> currentPatchSets;
-  private final Map<ApprovalKey, PatchSetApproval> approvals;
-  private final List<PatchSetApproval> bufferedApprovals;
+  private final Map<PatchSetApproval.Key, PatchSetApproval.Builder> approvals;
+  private final List<PatchSetApproval.Builder> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
 
   // Non-final private members filled in during the parsing process.
@@ -166,6 +148,7 @@
   private ReviewerSet pendingReviewers;
   private ReviewerByEmailSet pendingReviewersByEmail;
   private Change.Id revertOf;
+  private int updateCount;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -232,11 +215,11 @@
     return revisionNoteMap;
   }
 
-  private ChangeNotesState buildState() {
+  private ChangeNotesState buildState() throws ConfigInvalidException {
     return ChangeNotesState.create(
         tip.copy(),
         id,
-        new Change.Key(changeId),
+        Change.key(changeId),
         createdOn,
         lastUpdatedOn,
         ownerId,
@@ -250,7 +233,7 @@
         status,
         Sets.newLinkedHashSet(Lists.reverse(pastAssignees)),
         firstNonNull(hashtags, ImmutableSet.of()),
-        patchSets,
+        buildPatchSets(),
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
         ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
@@ -264,14 +247,30 @@
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
-        revertOf);
+        revertOf,
+        updateCount);
+  }
+
+  private Map<PatchSet.Id, PatchSet> buildPatchSets() throws ConfigInvalidException {
+    Map<PatchSet.Id, PatchSet> result = Maps.newHashMapWithExpectedSize(patchSets.size());
+    for (Map.Entry<PatchSet.Id, PatchSet.Builder> e : patchSets.entrySet()) {
+      try {
+        PatchSet ps = e.getValue().build();
+        result.put(ps.id(), ps);
+      } catch (Exception ex) {
+        ConfigInvalidException cie = parseException("Error building patch set %s", e.getKey());
+        cie.initCause(ex);
+        throw cie;
+      }
+    }
+    return result;
   }
 
   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)) {
+      if (patchSetCommitParsed(psId)) {
         return psId;
       }
     }
@@ -281,14 +280,14 @@
   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())) {
+    for (PatchSetApproval.Builder a : approvals.values()) {
+      if (!patchSetCommitParsed(a.key().patchSetId())) {
         continue; // Patch set deleted or missing.
-      } else if (allPastReviewers.contains(a.getAccountId())
-          && !reviewers.containsRow(a.getAccountId())) {
+      } else if (allPastReviewers.contains(a.key().accountId())
+          && !reviewers.containsRow(a.key().accountId())) {
         continue; // Reviewer was explicitly removed.
       }
-      result.put(a.getPatchSetId(), a);
+      result.put(a.key().patchSetId(), a.build());
     }
     result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
@@ -311,6 +310,7 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
+    updateCount++;
     Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
 
     createdOn = ts;
@@ -361,11 +361,14 @@
       submissionId = parseSubmissionId(commit);
     }
 
+    // Parse mutable patch set fields first so they can be recorded in the PendingPatchSetFields.
+    parseDescription(psId, commit);
+    parseGroups(psId, commit);
+
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
       parsePatchSet(psId, currRev, accountId, ts);
     }
-    parseGroups(psId, commit);
     parseCurrentPatchSet(psId, commit);
 
     if (submitRecords.isEmpty()) {
@@ -409,8 +412,6 @@
     if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
       lastUpdatedOn = ts;
     }
-
-    parseDescription(psId, commit);
   }
 
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -483,24 +484,27 @@
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
-    PatchSet ps = patchSets.get(psId);
-    if (ps == null) {
-      ps = new PatchSet(psId);
-      patchSets.put(psId, ps);
-    } else if (!ps.getRevision().equals(PARTIAL_PATCH_SET)) {
+    if (patchSetCommitParsed(psId)) {
       if (deletedPatchSets.contains(psId)) {
-        // Do not update PS details as PS was deleted and this meta data is of
-        // no relevance
+        // Do not update PS details as PS was deleted and this meta data is of no relevance.
         return;
       }
+      ObjectId commitId = patchSets.get(psId).commitId().orElseThrow(IllegalStateException::new);
       throw new ConfigInvalidException(
           String.format(
               "Multiple revisions parsed for patch set %s: %s and %s",
-              psId.get(), patchSets.get(psId).getRevision(), rev.name()));
+              psId.get(), commitId.name(), rev.name()));
     }
-    ps.setRevision(new RevId(rev.name()));
-    ps.setUploader(accountId);
-    ps.setCreatedOn(ts);
+    patchSets
+        .computeIfAbsent(psId, id -> PatchSet.builder())
+        .id(psId)
+        .commitId(rev)
+        .uploader(accountId)
+        .createdOn(ts);
+    // Fields not set here:
+    // * Groups, parsed earlier in parseGroups.
+    // * Description, parsed earlier in parseDescription.
+    // * Push certificate, parsed later in parseNotes.
   }
 
   private void parseGroups(PatchSet.Id psId, ChangeNotesCommit commit)
@@ -509,15 +513,11 @@
     if (groupsStr == null) {
       return;
     }
-    PatchSet ps = patchSets.get(psId);
-    if (ps == null) {
-      ps = new PatchSet(psId);
-      ps.setRevision(PARTIAL_PATCH_SET);
-      patchSets.put(psId, ps);
-    } else if (!ps.getGroups().isEmpty()) {
-      return;
+    checkPatchSetCommitNotParsed(psId, FOOTER_GROUPS);
+    PatchSet.Builder pending = patchSets.computeIfAbsent(psId, id -> PatchSet.builder());
+    if (pending.groups().isEmpty()) {
+      pending.groups(PatchSet.splitGroups(groupsStr));
     }
-    ps.setGroups(PatchSet.splitGroups(groupsStr));
   }
 
   private void parseCurrentPatchSet(PatchSet.Id psId, ChangeNotesCommit commit)
@@ -612,9 +612,9 @@
     // 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);
+      for (PatchSetApproval.Builder psa : bufferedApprovals) {
+        if (!psa.key().isLegacySubmit()) {
+          psa.postSubmit(true);
         }
       }
     }
@@ -630,7 +630,7 @@
     if (psId == null) {
       throw invalidFooter(FOOTER_PATCH_SET, psIdStr);
     }
-    return new PatchSet.Id(id, psId);
+    return PatchSet.id(id, psId);
   }
 
   private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -658,16 +658,14 @@
     List<String> descLines = commit.getFooterLineValues(FOOTER_PATCH_SET_DESCRIPTION);
     if (descLines.isEmpty()) {
       return;
-    } else if (descLines.size() == 1) {
+    }
+
+    checkPatchSetCommitNotParsed(psId, FOOTER_PATCH_SET_DESCRIPTION);
+    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);
+      PatchSet.Builder pending = patchSets.computeIfAbsent(psId, p -> PatchSet.builder());
+      if (!pending.description().isPresent()) {
+        pending.description(Optional.of(desc));
       }
     } else {
       throw expectedOneFooter(FOOTER_PATCH_SET_DESCRIPTION, descLines);
@@ -686,8 +684,7 @@
     }
 
     ChangeMessage changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(psId.getParentKey(), commit.name()), accountId, ts, psId);
+        new ChangeMessage(ChangeMessage.key(psId.changeId(), commit.name()), accountId, ts, psId);
     changeMessage.setMessage(changeMsgString.get());
     changeMessage.setTag(tag);
     changeMessage.setRealAuthor(realAccountId);
@@ -719,18 +716,23 @@
             reader,
             NoteMap.read(reader, tipCommit),
             PatchLineComment.Status.PUBLISHED);
-    Map<RevId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
+    Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
-    for (Map.Entry<RevId, ChangeRevisionNote> e : rns.entrySet()) {
+    for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
       for (Comment c : e.getValue().getEntities()) {
         comments.put(e.getKey(), c);
       }
     }
 
-    for (PatchSet ps : patchSets.values()) {
-      ChangeRevisionNote rn = rns.get(ps.getRevision());
+    for (PatchSet.Builder b : patchSets.values()) {
+      ObjectId commitId =
+          b.commitId()
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException("never parsed commit ID for patch set " + b.id()));
+      ChangeRevisionNote rn = rns.get(commitId);
       if (rn != null && rn.getPushCert() != null) {
-        ps.setPushCertificate(rn.getPushCert());
+        b.pushCertificate(Optional.of(rn.getPushCert()));
       }
     }
   }
@@ -741,7 +743,7 @@
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
-    PatchSetApproval psa;
+    PatchSetApproval.Builder psa;
     if (line.startsWith("-")) {
       psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
     } else {
@@ -750,7 +752,7 @@
     bufferedApprovals.add(psa);
   }
 
-  private PatchSetApproval parseAddApproval(
+  private PatchSetApproval.Builder parseAddApproval(
       PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
       throws ConfigInvalidException {
     // There are potentially 3 accounts involved here:
@@ -787,23 +789,20 @@
       throw pe;
     }
 
-    PatchSetApproval psa =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(l.label())),
-            l.value(),
-            ts);
-    psa.setTag(tag);
+    PatchSetApproval.Builder psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(l.label())))
+            .value(l.value())
+            .granted(ts)
+            .tag(Optional.ofNullable(tag));
     if (!Objects.equals(realAccountId, committerId)) {
-      psa.setRealAccountId(realAccountId);
+      psa.realAccountId(realAccountId);
     }
-    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, l.label());
-    if (!approvals.containsKey(k)) {
-      approvals.put(k, psa);
-    }
+    approvals.putIfAbsent(psa.key(), psa);
     return psa;
   }
 
-  private PatchSetApproval parseRemoveApproval(
+  private PatchSetApproval.Builder 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.
@@ -830,16 +829,15 @@
 
     // Store an actual 0-vote approval in the map for a removed approval, because 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);
+    PatchSetApproval.Builder remove =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(label)))
+            .value(0)
+            .granted(ts);
     if (!Objects.equals(realAccountId, committerId)) {
-      remove.setRealAccountId(realAccountId);
+      remove.realAccountId(realAccountId);
     }
-    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, label);
-    if (!approvals.containsKey(k)) {
-      approvals.put(k, remove);
-    }
+    approvals.putIfAbsent(remove.key(), remove);
     return remove;
   }
 
@@ -974,7 +972,7 @@
     if (revertOf == null) {
       throw invalidFooter(FOOTER_REVERT_OF, footer);
     }
-    return new Change.Id(revertOf);
+    return Change.id(revertOf);
   }
 
   private void pruneReviewers() {
@@ -1001,13 +999,7 @@
 
   private void updatePatchSetStates() {
     Set<PatchSet.Id> missing = new TreeSet<>(comparing(PatchSet.Id::get));
-    for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
-      PatchSet ps = it.next();
-      if (ps.getRevision().equals(PARTIAL_PATCH_SET)) {
-        missing.add(ps.getId());
-        it.remove();
-      }
-    }
+    missing.addAll(patchSets.keySet());
     for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
       switch (e.getValue()) {
         case PUBLISHED:
@@ -1028,10 +1020,10 @@
         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            comments.values(), c -> new PatchSet.Id(id, c.key.patchSetId), missing);
+            comments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            approvals.values(), PatchSetApproval::getPatchSetId, missing);
+            approvals.values(), psa -> psa.key().patchSetId(), missing);
 
     if (!missing.isEmpty()) {
       logger.atWarning().log(
@@ -1044,7 +1036,7 @@
     int pruned = 0;
     for (Iterator<T> it = ents.iterator(); it.hasNext(); ) {
       PatchSet.Id psId = psIdFunc.apply(it.next());
-      if (!patchSets.containsKey(psId)) {
+      if (!patchSetCommitParsed(psId)) {
         pruned++;
         missing.add(psId);
         it.remove();
@@ -1087,6 +1079,20 @@
     }
   }
 
+  private void checkPatchSetCommitNotParsed(PatchSet.Id psId, FooterKey footer)
+      throws ConfigInvalidException {
+    if (patchSetCommitParsed(psId)) {
+      throw parseException(
+          "%s field found for patch set %s before patch set was originally defined",
+          footer.getName(), psId.get());
+    }
+  }
+
+  private boolean patchSetCommitParsed(PatchSet.Id psId) {
+    PatchSet.Builder pending = patchSets.get(psId);
+    return pending != null && pending.commitId().isPresent();
+  }
+
   private ConfigInvalidException parseException(String fmt, Object... args) {
     return ChangeNotes.parseException(id, fmt, args);
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index fd260e7..2728516 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -39,14 +39,13 @@
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.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.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
@@ -117,11 +116,12 @@
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
-      ListMultimap<RevId, Comment> publishedComments,
+      ListMultimap<ObjectId, Comment> publishedComments,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
-      @Nullable Change.Id revertOf) {
+      @Nullable Change.Id revertOf,
+      int updateCount) {
     requireNonNull(
         metaId,
         () ->
@@ -165,6 +165,7 @@
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
+        .updateCount(updateCount)
         .build();
   }
 
@@ -295,7 +296,9 @@
 
   abstract ImmutableList<ChangeMessage> changeMessages();
 
-  abstract ImmutableListMultimap<RevId, Comment> publishedComments();
+  abstract ImmutableListMultimap<ObjectId, Comment> publishedComments();
+
+  abstract int updateCount();
 
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
@@ -304,7 +307,7 @@
             c.changeKey(),
             changeId(),
             c.owner(),
-            new Branch.NameKey(project, c.branch()),
+            BranchNameKey.create(project, c.branch()),
             c.createdOn());
     copyNonConstructorColumnsTo(change);
     return change;
@@ -318,7 +321,7 @@
         this);
     change.setKey(c.changeKey());
     change.setOwner(c.owner());
-    change.setDest(new Branch.NameKey(change.getProject(), c.branch()));
+    change.setDest(BranchNameKey.create(change.getProject(), c.branch()));
     change.setCreatedOn(c.createdOn());
     copyNonConstructorColumnsTo(change);
   }
@@ -363,7 +366,8 @@
           .reviewerUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
-          .publishedComments(ImmutableListMultimap.of());
+          .publishedComments(ImmutableListMultimap.of())
+          .updateCount(0);
     }
 
     abstract Builder metaId(ObjectId metaId);
@@ -396,7 +400,9 @@
 
     abstract Builder changeMessages(List<ChangeMessage> changeMessages);
 
-    abstract Builder publishedComments(ListMultimap<RevId, Comment> publishedComments);
+    abstract Builder publishedComments(ListMultimap<ObjectId, Comment> publishedComments);
+
+    abstract Builder updateCount(int updateCount);
 
     abstract ChangeNotesState build();
   }
@@ -459,6 +465,7 @@
           .changeMessages()
           .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
+      b.setUpdateCount(object.updateCount());
 
       return Protos.toByteArray(b.build());
     }
@@ -535,7 +542,7 @@
     @Override
     public ChangeNotesState deserialize(byte[] in) {
       ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
-      Change.Id changeId = new Change.Id(proto.getChangeId());
+      Change.Id changeId = Change.id(proto.getChangeId());
 
       ChangeNotesState.Builder b =
           builder()
@@ -543,28 +550,24 @@
               .changeId(changeId)
               .columns(toChangeColumns(changeId, proto.getColumns()))
               .pastAssignees(
-                  proto.getPastAssigneeList().stream()
-                      .map(Account.Id::new)
-                      .collect(toImmutableSet()))
+                  proto.getPastAssigneeList().stream().map(Account::id).collect(toImmutableSet()))
               .hashtags(proto.getHashtagList())
               .patchSets(
                   proto.getPatchSetList().stream()
                       .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
-                      .map(ps -> Maps.immutableEntry(ps.getId(), ps))
+                      .map(ps -> Maps.immutableEntry(ps.id(), ps))
                       .collect(toImmutableList()))
               .approvals(
                   proto.getApprovalList().stream()
                       .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
-                      .map(a -> Maps.immutableEntry(a.getPatchSetId(), a))
+                      .map(a -> Maps.immutableEntry(a.patchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
               .reviewersByEmail(toReviewerByEmailSet(proto.getReviewerByEmailList()))
               .pendingReviewers(toReviewerSet(proto.getPendingReviewerList()))
               .pendingReviewersByEmail(toReviewerByEmailSet(proto.getPendingReviewerByEmailList()))
               .allPastReviewers(
-                  proto.getPastReviewerList().stream()
-                      .map(Account.Id::new)
-                      .collect(toImmutableList()))
+                  proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -577,7 +580,8 @@
               .publishedComments(
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, Comment.class))
-                      .collect(toImmutableListMultimap(c -> new RevId(c.revId), c -> c)));
+                      .collect(toImmutableListMultimap(Comment::getCommitId, c -> c)))
+              .updateCount(proto.getUpdateCount());
       return b.build();
     }
 
@@ -590,13 +594,13 @@
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
-              .changeKey(new Change.Key(proto.getChangeKey()))
+              .changeKey(Change.key(proto.getChangeKey()))
               .createdOn(new Timestamp(proto.getCreatedOn()))
               .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()))
-              .owner(new Account.Id(proto.getOwner()))
+              .owner(Account.id(proto.getOwner()))
               .branch(proto.getBranch());
       if (proto.getHasCurrentPatchSetId()) {
-        b.currentPatchSetId(new PatchSet.Id(changeId, proto.getCurrentPatchSetId()));
+        b.currentPatchSetId(PatchSet.id(changeId, proto.getCurrentPatchSetId()));
       }
       b.subject(proto.getSubject());
       if (proto.getHasTopic()) {
@@ -609,7 +613,7 @@
         b.submissionId(proto.getSubmissionId());
       }
       if (proto.getHasAssignee()) {
-        b.assignee(new Account.Id(proto.getAssignee()));
+        b.assignee(Account.id(proto.getAssignee()));
       }
       if (proto.getHasStatus()) {
         b.status(STATUS_CONVERTER.convert(proto.getStatus()));
@@ -618,7 +622,7 @@
           .workInProgress(proto.getWorkInProgress())
           .reviewStarted(proto.getReviewStarted());
       if (proto.getHasRevertOf()) {
-        b.revertOf(new Change.Id(proto.getRevertOf()));
+        b.revertOf(Change.id(proto.getRevertOf()));
       }
       return b.build();
     }
@@ -629,7 +633,7 @@
       for (ReviewerSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
-            new Account.Id(e.getAccountId()),
+            Account.id(e.getAccountId()),
             new Timestamp(e.getTimestamp()));
       }
       return ReviewerSet.fromTable(b.build());
@@ -655,8 +659,8 @@
         b.add(
             ReviewerStatusUpdate.create(
                 new Timestamp(proto.getDate()),
-                new Account.Id(proto.getUpdatedBy()),
-                new Account.Id(proto.getReviewer()),
+                Account.id(proto.getUpdatedBy()),
+                Account.id(proto.getReviewer()),
                 REVIEWER_STATE_CONVERTER.convert(proto.getState())));
       }
       return b.build();
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index ea65f21..8e751de 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -39,7 +39,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
-import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -57,14 +57,12 @@
 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.client.RobotComment;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.client.IntKey;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -173,7 +171,7 @@
 
   private static Table<String, Account.Id, Optional<Short>> approvals(
       Comparator<String> nameComparator) {
-    return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
+    return TreeBasedTable.create(nameComparator, naturalOrder());
   }
 
   @AssistedInject
@@ -250,11 +248,6 @@
     checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time");
   }
 
-  @Deprecated // Only until we improve ChangeRebuilder to call merge().
-  public void setSubmissionId(String submissionId) {
-    this.submissionId = submissionId;
-  }
-
   public void setSubjectForCommit(String commitSubject) {
     this.commitSubject = commitSubject;
   }
@@ -287,11 +280,7 @@
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
-      // Always delete the corresponding comment from drafts. Published comments
-      // are immutable, meaning in normal operation we only hit this path when
-      // publishing a comment. It's exactly in that case that we have to delete
-      // the draft.
-      draftUpdate.deleteComment(c);
+      draftUpdate.markCommentPublished(c);
     }
   }
 
@@ -421,7 +410,7 @@
   }
 
   public void setRevertOf(int revertOf) {
-    int ownId = getChange().getId().get();
+    int ownId = getId().get();
     checkArgument(ownId != revertOf, "A change cannot revert itself");
     this.revertOf = revertOf;
     rootOnly = true;
@@ -438,18 +427,18 @@
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
     for (Comment c : comments) {
       c.tag = tag;
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
     if (pushCert != null) {
       checkState(commit != null);
-      cache.get(new RevId(commit)).setPushCertificate(pushCert);
+      cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert);
     }
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     checkComments(rnm.revisionNotes, builders);
 
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
       ObjectId data = inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil.getChangeNoteJson()));
-      rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
+      rnm.noteMap.set(e.getKey(), data);
     }
 
     return rnm.noteMap.writeTree(inserter);
@@ -482,7 +471,8 @@
   }
 
   private void checkComments(
-      Map<RevId, ChangeRevisionNote> existingNotes, Map<RevId, RevisionNoteBuilder> toUpdate) {
+      Map<ObjectId, ChangeRevisionNote> existingNotes,
+      Map<ObjectId, RevisionNoteBuilder> toUpdate) {
     // Prohibit various kinds of illegal operations on comments.
     Set<Comment.Key> existing = new HashSet<>();
     for (ChangeRevisionNote rn : existingNotes.values()) {
@@ -504,7 +494,7 @@
           // 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.revId, c.key);
+          draftUpdate.deleteComment(c.getCommitId(), c.key);
         }
       }
     }
@@ -524,6 +514,12 @@
   }
 
   @Override
+  protected boolean bypassMaxUpdates() {
+    // Allow abandoning or submitting a change even if it would exceed the max update count.
+    return status != null && status.isClosed();
+  }
+
+  @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws IOException {
     checkState(
diff --git a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
index 8fb28e1..6d0530a 100644
--- a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
@@ -15,9 +15,9 @@
 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.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
+import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.util.RawParseUtils.decode;
 
 import com.google.gerrit.reviewdb.client.Change;
@@ -46,7 +46,7 @@
 
   DeleteChangeMessageRewriter(Change.Id changeId, String targetMessageId, String newChangeMessage) {
     this.changeId = changeId;
-    this.targetMessageId = checkNotNull(targetMessageId);
+    this.targetMessageId = requireNonNull(targetMessageId);
     this.newChangeMessage = newChangeMessage;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index c100550..dceffa3 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -224,16 +223,16 @@
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
 
     for (Comment c : putInComments) {
-      cache.get(new RevId(c.revId)).putComment(c);
+      cache.get(c.getCommitId()).putComment(c);
     }
 
     for (Comment c : deletedComments) {
-      cache.get(new RevId(c.revId)).deleteComment(c.key);
+      cache.get(c.getCommitId()).deleteComment(c.key);
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
-    for (Map.Entry<RevId, RevisionNoteBuilder> entry : builders.entrySet()) {
-      ObjectId objectId = ObjectId.fromString(entry.getKey().get());
+    Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> entry : builders.entrySet()) {
+      ObjectId objectId = entry.getKey();
       byte[] data = entry.getValue().build(noteUtil.getChangeNoteJson());
       if (data.length == 0) {
         revNotesMap.noteMap.remove(objectId);
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 213613e..e62c396 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -29,7 +29,6 @@
 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.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -52,7 +51,7 @@
   private final Account.Id author;
   private final Ref ref;
 
-  private ImmutableListMultimap<RevId, Comment> comments;
+  private ImmutableListMultimap<ObjectId, Comment> comments;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
@@ -82,7 +81,7 @@
     return author;
   }
 
-  public ImmutableListMultimap<RevId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, Comment> getComments() {
     return comments;
   }
 
@@ -128,10 +127,10 @@
             reader,
             NoteMap.read(reader, tipCommit),
             PatchLineComment.Status.DRAFT);
-    ListMultimap<RevId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ListMultimap<ObjectId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (Comment c : rn.getEntities()) {
-        cs.put(new RevId(c.revId), c);
+        cs.put(c.getCommitId(), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
index 916cc16..36bfe47 100644
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
+++ b/java/com/google/gerrit/server/notedb/LegacyChangeNoteRead.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.CommentRange;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
@@ -34,6 +33,7 @@
 import java.util.Locale;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.GitDateParser;
 import org.eclipse.jgit.util.MutableInteger;
@@ -77,7 +77,8 @@
     byte[] bpsb = ChangeNoteUtil.BASE_PATCH_SET.getBytes(UTF_8);
     byte[] bpn = ChangeNoteUtil.PARENT_NUMBER.getBytes(UTF_8);
 
-    RevId revId = new RevId(parseStringField(note, p, changeId, ChangeNoteUtil.REVISION));
+    ObjectId commitId =
+        ObjectId.fromString(parseStringField(note, p, changeId, ChangeNoteUtil.REVISION));
     String fileName = null;
     PatchSet.Id psId = null;
     boolean isForBase = false;
@@ -105,7 +106,7 @@
             ChangeNoteUtil.BASE_PATCH_SET);
       }
 
-      Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber);
+      Comment c = parseComment(note, p, fileName, psId, commitId, isForBase, parentNumber);
       fileName = c.key.filename;
       if (!seen.add(c.key)) {
         throw parseException(changeId, "multiple comments for %s in note", c.key);
@@ -120,11 +121,11 @@
       MutableInteger curr,
       String currentFileName,
       PatchSet.Id psId,
-      RevId revId,
+      ObjectId commitId,
       boolean isForBase,
       Integer parentNumber)
       throws ConfigInvalidException {
-    Change.Id changeId = psId.getParentKey();
+    Change.Id changeId = psId.changeId();
 
     // Check if there is a new file.
     boolean newFile =
@@ -189,7 +190,7 @@
     c.lineNbr = range.getEndLine();
     c.parentUuid = parentUUID;
     c.tag = tag;
-    c.setRevId(revId);
+    c.setCommitId(commitId);
     if (raId != null) {
       c.setRealAuthor(raId);
     }
@@ -285,7 +286,7 @@
     }
     checkResult(patchSetId, "patchset id", changeId);
     curr.value = endOfLine;
-    return new PatchSet.Id(changeId, patchSetId);
+    return PatchSet.id(changeId, patchSetId);
   }
 
   private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId)
diff --git a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
index b51a59c..1e6e9e8 100644
--- a/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
+++ b/java/com/google/gerrit/server/notedb/LegacyChangeNoteWrite.java
@@ -34,6 +34,7 @@
 import java.sql.Timestamp;
 import java.util.Date;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.QuotedString;
 
@@ -77,7 +78,7 @@
    *
    * @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
+   *     comments must share the same commitId, and all the comments for a given patch set must have
    *     the same side.
    * @param out output stream to write to.
    */
@@ -91,8 +92,9 @@
 
     OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
     try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      String revId = comments.values().iterator().next().revId;
-      appendHeaderField(writer, ChangeNoteUtil.REVISION, revId);
+      ObjectId commitId = comments.values().iterator().next().getCommitId();
+      String commitName = commitId.name();
+      appendHeaderField(writer, ChangeNoteUtil.REVISION, commitName);
 
       for (int psId : psIds) {
         List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
@@ -111,11 +113,11 @@
 
         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 "
+              commitId.equals(c.getCommitId()),
+              "All comments being added must have all the same commitId. The "
+                  + "comment below does not have the same commitId as the others "
                   + "(%s).\n%s",
-              revId,
+              commitId,
               c);
           checkArgument(
               side == c.side,
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index ea42a9d..e3a9a92 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -18,15 +18,11 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Change;
@@ -34,11 +30,9 @@
 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.config.GerritServerConfig;
 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.update.ChainedReceiveCommands;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -50,9 +44,9 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
+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;
@@ -70,85 +64,16 @@
  * {@link #stage()}.
  */
 public class NoteDbUpdateManager implements AutoCloseable {
-  private static final ImmutableList<String> PACKAGE_PREFIXES =
-      ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
-  private static final ImmutableSet<String> SERVLET_NAMES =
-      ImmutableSet.of(
-          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
-
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
   }
 
-  public static class OpenRepo implements AutoCloseable {
-    public final Repository repo;
-    public final RevWalk rw;
-    public final ChainedReceiveCommands cmds;
-
-    private final InMemoryInserter inMemIns;
-    private final ObjectInserter tempIns;
-    @Nullable private final ObjectInserter finalIns;
-
-    private final 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,
-          "expected reader to be created from %s, but was %s",
-          ins,
-          reader.getCreatedFromInserter());
-      this.repo = requireNonNull(repo);
-
-      this.inMemIns = new InMemoryInserter(rw.getObjectReader());
-      this.tempIns = inMemIns;
-
-      this.rw = new RevWalk(tempIns.newReader());
-      this.finalIns = ins;
-      this.cmds = requireNonNull(cmds);
-      this.close = close;
-    }
-
-    public Optional<ObjectId> getObjectId(String refName) throws IOException {
-      return cmds.get(refName);
-    }
-
-    void flush() throws IOException {
-      flushToFinalInserter();
-      finalIns.flush();
-    }
-
-    void flushToFinalInserter() throws IOException {
-      checkState(finalIns != null);
-      for (InsertedObject obj : inMemIns.getInsertedObjects()) {
-        finalIns.insert(obj.type(), obj.data().toByteArray());
-      }
-      inMemIns.clear();
-    }
-
-    @Override
-    public void close() {
-      rw.getObjectReader().close();
-      rw.close();
-      if (close) {
-        if (finalIns != null) {
-          finalIns.close();
-        }
-        repo.close();
-      }
-    }
-  }
-
   private final Provider<PersonIdent> serverIdent;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final NoteDbMetrics metrics;
   private final Project.NameKey projectName;
+  private final int maxUpdates;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
@@ -157,6 +82,7 @@
 
   private OpenRepo changeRepo;
   private OpenRepo allUsersRepo;
+  private AllUsersAsyncUpdate updateAllUsersAsync;
   private boolean executed;
   private String refLogMessage;
   private PersonIdent refLogIdent;
@@ -164,16 +90,20 @@
 
   @Inject
   NoteDbUpdateManager(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       NoteDbMetrics metrics,
+      AllUsersAsyncUpdate updateAllUsersAsync,
       @Assisted Project.NameKey projectName) {
     this.serverIdent = serverIdent;
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.metrics = metrics;
+    this.updateAllUsersAsync = updateAllUsersAsync;
     this.projectName = projectName;
+    maxUpdates = cfg.getInt("change", null, "maxUpdates", 1000);
     changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
@@ -236,28 +166,13 @@
 
   private void initChangeRepo() throws IOException {
     if (changeRepo == null) {
-      changeRepo = openRepo(projectName);
+      changeRepo = OpenRepo.open(repoManager, projectName);
     }
   }
 
   private void initAllUsersRepo() throws IOException {
     if (allUsersRepo == null) {
-      allUsersRepo = openRepo(allUsersName);
-    }
-  }
-
-  private OpenRepo openRepo(Project.NameKey p) throws IOException {
-    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();
-        }
-      };
+      allUsersRepo = OpenRepo.open(repoManager, allUsersName);
     }
   }
 
@@ -268,7 +183,8 @@
         && rewriters.isEmpty()
         && toDelete.isEmpty()
         && !hasCommands(changeRepo)
-        && !hasCommands(allUsersRepo);
+        && !hasCommands(allUsersRepo)
+        && updateAllUsersAsync.isEmpty();
   }
 
   private static boolean hasCommands(@Nullable OpenRepo or) {
@@ -398,6 +314,13 @@
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
       BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
       execute(allUsersRepo, dryrun, null);
+      if (!dryrun) {
+        // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
+        // have to run synchronous to be of any value at all. For the removal of draft comments from
+        // All-Users we don't care much of the operation succeeds, so we are skipping the dry run
+        // altogether.
+        updateAllUsersAsync.execute(refLogIdent, refLogMessage, pushCert);
+      }
       executed = true;
       return result;
     } finally {
@@ -423,7 +346,8 @@
     if (refLogMessage != null) {
       bru.setRefLogMessage(refLogMessage, false);
     } else {
-      bru.setRefLogMessage(firstNonNull(guessRestApiHandler(), "Update NoteDb refs"), false);
+      bru.setRefLogMessage(
+          firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs"), false);
     }
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     bru.setAtomic(true);
@@ -436,59 +360,18 @@
     return bru;
   }
 
-  private static String guessRestApiHandler() {
-    StackTraceElement[] trace = Thread.currentThread().getStackTrace();
-    int i = findRestApiServlet(trace);
-    if (i < 0) {
-      return null;
-    }
-    try {
-      for (i--; i >= 0; i--) {
-        String cn = trace[i].getClassName();
-        Class<?> cls = Class.forName(cn);
-        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
-          return viewName(cn);
-        }
-      }
-      return null;
-    } catch (ClassNotFoundException e) {
-      return null;
-    }
-  }
-
-  private static String viewName(String cn) {
-    String impl = cn.replace('$', '.');
-    for (String p : PACKAGE_PREFIXES) {
-      if (impl.startsWith(p)) {
-        return impl.substring(p.length());
-      }
-    }
-    return impl;
-  }
-
-  private static int findRestApiServlet(StackTraceElement[] trace) {
-    for (int i = 0; i < trace.length; i++) {
-      if (SERVLET_NAMES.contains(trace[i].getClassName())) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
   private void addCommands() throws IOException {
-    if (isEmpty()) {
-      return;
-    }
-    checkState(changeRepo != null, "must set change repo");
+    changeRepo.addUpdates(changeUpdates, Optional.of(maxUpdates));
     if (!draftUpdates.isEmpty()) {
-      checkState(allUsersRepo != null, "must set all users repo");
-    }
-    addUpdates(changeUpdates, changeRepo);
-    if (!draftUpdates.isEmpty()) {
-      addUpdates(draftUpdates, allUsersRepo);
+      boolean publishOnly = draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+      if (publishOnly) {
+        updateAllUsersAsync.setDraftUpdates(draftUpdates);
+      } else {
+        allUsersRepo.addUpdates(draftUpdates);
+      }
     }
     if (!robotCommentUpdates.isEmpty()) {
-      addUpdates(robotCommentUpdates, changeRepo);
+      changeRepo.addUpdates(robotCommentUpdates);
     }
     if (!rewriters.isEmpty()) {
       addRewrites(rewriters, changeRepo);
@@ -520,36 +403,6 @@
     checkState(!executed, "update has already been executed");
   }
 
-  private static <U extends AbstractChangeUpdate> void addUpdates(
-      ListMultimap<String, U> all, OpenRepo or) throws 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).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.
-      if (!allowWrite(updates, old)) {
-        continue;
-      }
-
-      ObjectId curr = old;
-      for (U u : updates) {
-        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
-          throw new StorageException("Given ChangeUpdate is only allowed on initial commit");
-        }
-        ObjectId next = u.apply(or.rw, or.tempIns, curr);
-        if (next == null) {
-          continue;
-        }
-        curr = next;
-      }
-      if (!old.equals(curr)) {
-        or.cmds.add(new ReceiveCommand(old, curr, refName));
-      }
-    }
-  }
-
   private static void addRewrites(ListMultimap<String, NoteDbRewriter> rewriters, OpenRepo openRepo)
       throws IOException {
     for (Map.Entry<String, Collection<NoteDbRewriter>> entry : rewriters.asMap().entrySet()) {
@@ -578,12 +431,4 @@
       }
     }
   }
-
-  private static <U extends AbstractChangeUpdate> boolean allowWrite(
-      Collection<U> updates, ObjectId old) {
-    if (!old.equals(ObjectId.zeroId())) {
-      return true;
-    }
-    return updates.iterator().next().allowWriteToNewRef();
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 667ceab..c53f4b9 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import java.sql.Timestamp;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -25,6 +29,14 @@
 
 public class NoteDbUtil {
 
+  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
+
+  private static final ImmutableList<String> PACKAGE_PREFIXES =
+      ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
+  private static final ImmutableSet<String> SERVLET_NAMES =
+      ImmutableSet.of(
+          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
+
   /**
    * Returns an AccountId for the given email address. Returns empty if the address isn't on this
    * server.
@@ -37,15 +49,13 @@
       if (host.equals(serverId)) {
         Integer id = Ints.tryParse(email.substring(0, at));
         if (id != null) {
-          return Optional.of(new Account.Id(id));
+          return Optional.of(Account.id(id));
         }
       }
     }
     return Optional.empty();
   }
 
-  private NoteDbUtil() {}
-
   public static String formatTime(PersonIdent ident, Timestamp t) {
     GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
     // TODO(dborowitz): Use a ThreadLocal or use Joda.
@@ -53,7 +63,29 @@
     return dateFormatter.formatDate(newIdent);
   }
 
-  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
+  /**
+   * Returns the name of the REST API handler that is in the stack trace of the caller of this
+   * method.
+   */
+  static String guessRestApiHandler() {
+    StackTraceElement[] trace = Thread.currentThread().getStackTrace();
+    int i = findRestApiServlet(trace);
+    if (i < 0) {
+      return null;
+    }
+    try {
+      for (i--; i >= 0; i--) {
+        String cn = trace[i].getClassName();
+        Class<?> cls = Class.forName(cn);
+        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
+          return viewName(cn);
+        }
+      }
+      return null;
+    } catch (ClassNotFoundException e) {
+      return null;
+    }
+  }
 
   static String sanitizeFooter(String value) {
     // Remove characters that would confuse JGit's footer parser if they were
@@ -65,4 +97,25 @@
     // empty paragraph for the purposes of footer parsing.
     return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' ');
   }
+
+  private static int findRestApiServlet(StackTraceElement[] trace) {
+    for (int i = 0; i < trace.length; i++) {
+      if (SERVLET_NAMES.contains(trace[i].getClassName())) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  private static String viewName(String cn) {
+    String impl = cn.replace('$', '.');
+    for (String p : PACKAGE_PREFIXES) {
+      if (impl.startsWith(p)) {
+        return impl.substring(p.length());
+      }
+    }
+    return impl;
+  }
+
+  private NoteDbUtil() {}
 }
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
new file mode 100644
index 0000000..4595607
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.Project;
+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.update.ChainedReceiveCommands;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+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.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Wrapper around {@link Repository} that keeps track of related {@link ObjectInserter}s and other
+ * objects that are jointly closed when invoking {@link #close}.
+ */
+class OpenRepo implements AutoCloseable {
+  /** Returns a {@link OpenRepo} wrapping around an open {@link Repository}. */
+  static OpenRepo open(GitRepositoryManager repoManager, Project.NameKey project)
+      throws IOException {
+    Repository repo = repoManager.openRepository(project); // 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();
+        }
+      };
+    }
+  }
+
+  final Repository repo;
+  final RevWalk rw;
+  final ChainedReceiveCommands cmds;
+  final ObjectInserter tempIns;
+
+  private final InMemoryInserter inMemIns;
+  @Nullable private final ObjectInserter finalIns;
+  private final boolean close;
+
+  OpenRepo(
+      Repository repo,
+      RevWalk rw,
+      @Nullable ObjectInserter ins,
+      ChainedReceiveCommands cmds,
+      boolean close) {
+    ObjectReader reader = rw.getObjectReader();
+    checkArgument(
+        ins == null || reader.getCreatedFromInserter() == ins,
+        "expected reader to be created from %s, but was %s",
+        ins,
+        reader.getCreatedFromInserter());
+    this.repo = requireNonNull(repo);
+
+    this.inMemIns = new InMemoryInserter(rw.getObjectReader());
+    this.tempIns = inMemIns;
+
+    this.rw = new RevWalk(tempIns.newReader());
+    this.finalIns = ins;
+    this.cmds = requireNonNull(cmds);
+    this.close = close;
+  }
+
+  @Override
+  public void close() {
+    rw.getObjectReader().close();
+    rw.close();
+    if (close) {
+      if (finalIns != null) {
+        finalIns.close();
+      }
+      repo.close();
+    }
+  }
+
+  void flush() throws IOException {
+    flushToFinalInserter();
+    finalIns.flush();
+  }
+
+  void flushToFinalInserter() throws IOException {
+    checkState(finalIns != null);
+    for (InsertedObject obj : inMemIns.getInsertedObjects()) {
+      finalIns.insert(obj.type(), obj.data().toByteArray());
+    }
+    inMemIns.clear();
+  }
+
+  private static <U extends AbstractChangeUpdate> boolean allowWrite(
+      Collection<U> updates, ObjectId old) {
+    if (!old.equals(ObjectId.zeroId())) {
+      return true;
+    }
+    return updates.iterator().next().allowWriteToNewRef();
+  }
+
+  <U extends AbstractChangeUpdate> void addUpdates(ListMultimap<String, U> all) throws IOException {
+    addUpdates(all, Optional.empty());
+  }
+
+  <U extends AbstractChangeUpdate> void addUpdates(
+      ListMultimap<String, U> all, Optional<Integer> maxUpdates) throws IOException {
+    for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
+      String refName = e.getKey();
+      Collection<U> updates = e.getValue();
+      ObjectId old = 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.
+      if (!allowWrite(updates, old)) {
+        continue;
+      }
+
+      int updateCount;
+      U first = updates.iterator().next();
+      if (maxUpdates.isPresent()) {
+        checkState(first.getNotes() != null, "expected ChangeNotes on %s", first);
+        updateCount = first.getNotes().getUpdateCount();
+      } else {
+        updateCount = 0;
+      }
+
+      ObjectId curr = old;
+      for (U u : updates) {
+        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
+          throw new StorageException("Given ChangeUpdate is only allowed on initial commit");
+        }
+        ObjectId next = u.apply(rw, tempIns, curr);
+        if (next == null) {
+          continue;
+        }
+        if (maxUpdates.isPresent()
+            && !Objects.equals(next, curr)
+            && ++updateCount > maxUpdates.get()
+            && !u.bypassMaxUpdates()) {
+          throw new TooManyUpdatesException(u.getId(), maxUpdates.get());
+        }
+        curr = next;
+      }
+      if (!old.equals(curr)) {
+        cmds.add(new ReceiveCommand(old, curr, refName));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index ac7a89d..b0364e0 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Maps;
 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;
@@ -33,27 +32,29 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 class RevisionNoteBuilder {
   static class Cache {
     private final RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap;
-    private final Map<RevId, RevisionNoteBuilder> builders;
+    private final Map<ObjectId, RevisionNoteBuilder> builders;
 
     Cache(RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap) {
       this.revisionNoteMap = revisionNoteMap;
       this.builders = new HashMap<>();
     }
 
-    RevisionNoteBuilder get(RevId revId) {
-      RevisionNoteBuilder b = builders.get(revId);
+    RevisionNoteBuilder get(AnyObjectId commitId) {
+      RevisionNoteBuilder b = builders.get(commitId);
       if (b == null) {
-        b = new RevisionNoteBuilder(revisionNoteMap.revisionNotes.get(revId));
-        builders.put(revId, b);
+        b = new RevisionNoteBuilder(revisionNoteMap.revisionNotes.get(commitId));
+        builders.put(commitId.copy(), b);
       }
       return b;
     }
 
-    Map<RevId, RevisionNoteBuilder> getBuilders() {
+    Map<ObjectId, RevisionNoteBuilder> getBuilders() {
       return Collections.unmodifiableMap(builders);
     }
   }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index da790e2..03f912b 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -18,18 +18,18 @@
 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.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 
 class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
   final NoteMap noteMap;
-  final ImmutableMap<RevId, T> revisionNotes;
+  final ImmutableMap<ObjectId, T> revisionNotes;
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
       ChangeNoteJson noteJson,
@@ -39,13 +39,13 @@
       NoteMap noteMap,
       PatchLineComment.Status status)
       throws ConfigInvalidException, IOException {
-    Map<RevId, ChangeRevisionNote> result = new HashMap<>();
+    Map<ObjectId, ChangeRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
       ChangeRevisionNote rn =
           new ChangeRevisionNote(
               noteJson, legacyChangeNoteRead, changeId, reader, note.getData(), status);
       rn.parse();
-      result.put(new RevId(note.name()), rn);
+      result.put(note.copy(), rn);
     }
     return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
@@ -53,12 +53,12 @@
   static RevisionNoteMap<RobotCommentsRevisionNote> parseRobotComments(
       ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws ConfigInvalidException, IOException {
-    Map<RevId, RobotCommentsRevisionNote> result = new HashMap<>();
+    Map<ObjectId, RobotCommentsRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
       RobotCommentsRevisionNote rn =
           new RobotCommentsRevisionNote(changeNoteJson, reader, note.getData());
       rn.parse();
-      result.put(new RevId(note.name()), rn);
+      result.put(note.copy(), rn);
     }
     return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
@@ -67,7 +67,7 @@
     return new RevisionNoteMap<>(NoteMap.newEmptyMap(), ImmutableMap.of());
   }
 
-  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<RevId, T> revisionNotes) {
+  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<ObjectId, T> revisionNotes) {
     this.noteMap = noteMap;
     this.revisionNotes = revisionNotes;
   }
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index 92dd7d8..e863652 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -22,7 +22,6 @@
 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.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -42,7 +41,7 @@
 
   private final Change change;
 
-  private ImmutableListMultimap<RevId, RobotComment> comments;
+  private ImmutableListMultimap<ObjectId, RobotComment> comments;
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
   private ObjectId metaId;
 
@@ -56,7 +55,7 @@
     return revisionNoteMap;
   }
 
-  public ImmutableListMultimap<RevId, RobotComment> getComments() {
+  public ImmutableListMultimap<ObjectId, RobotComment> getComments() {
     return comments;
   }
 
@@ -95,10 +94,10 @@
     revisionNoteMap =
         RevisionNoteMap.parseRobotComments(
             args.changeNoteJson, reader, NoteMap.read(reader, tipCommit));
-    ListMultimap<RevId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ListMultimap<ObjectId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (RobotCommentsRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (RobotComment c : rn.getEntities()) {
-        cs.put(new RevId(c.revId), c);
+        cs.put(c.getCommitId(), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 0304ab8..a31f511 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -23,7 +23,6 @@
 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.inject.assistedinject.Assisted;
@@ -103,19 +102,19 @@
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
     RevisionNoteMap<RobotCommentsRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    Set<ObjectId> 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);
+      cache.get(c.getCommitId()).putComment(c);
     }
 
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    Map<ObjectId, 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());
+    for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
+      ObjectId id = e.getKey();
+      updatedRevs.add(id);
       byte[] data = e.getValue().build(noteUtil);
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
diff --git a/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java b/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
new file mode 100644
index 0000000..421e8c4
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.annotations.VisibleForTesting;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.reviewdb.client.Change;
+
+/**
+ * Exception indicating that the change has received too many updates. Further actions apart from
+ * {@code abandon} or {@code submit} are blocked.
+ */
+public class TooManyUpdatesException extends StorageException {
+  @VisibleForTesting
+  public static String message(Change.Id id, int maxUpdates) {
+    return "Change "
+        + id
+        + " may not exceed "
+        + maxUpdates
+        + " updates. It may still be abandoned or submitted. To continue working on this "
+        + "change, recreate it with a new Change-Id, then abandon this one.";
+  }
+
+  private static final long serialVersionUID = 1L;
+
+  TooManyUpdatesException(Change.Id id, int maxUpdates) {
+    super(message(id, maxUpdates));
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index 1593b11..ec02485 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -39,6 +39,15 @@
   private final RevTree aTree;
   private final RevTree bTree;
 
+  // Full text of both sides of the file. For standard files, these are not directly reconstructable
+  // from the PatchListEntry, which comes from the PatchListCache and only contains the diff between
+  // the two blobs. This is intentional, to avoid storing entire large blobs in the cache. For
+  // regular files, the full text is initialized from the repo lazily only when necessary, e.g. in
+  // getLine. Although it's a safe assumption that any caller constructing a PatchSet will want to
+  // read some content, we don't know in advance which side they are interested in.
+  //
+  // For special files like COMMIT_MSG, the full text is loaded eagerly during the constructor.
+  // TODO(dborowitz): I see why the logic is different, but I don't see why it needs to be eager.
   private Text a;
   private Text b;
 
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index dd717ba..35df1f5 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -25,6 +25,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import java.io.ByteArrayInputStream;
@@ -78,7 +79,7 @@
       boolean isMerge,
       ComparisonType comparisonType,
       PatchListEntry[] patches) {
-    this.oldId = oldId != null ? oldId.copy() : null;
+    this.oldId = ObjectIds.copyOrNull(oldId);
     this.newId = newId.copy();
     this.isMerge = isMerge;
     this.comparisonType = comparisonType;
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index 6039fff..8201947 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -134,10 +134,7 @@
   private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
       throws PatchListNotAvailableException {
     Project.NameKey project = change.getProject();
-    if (patchSet.getRevision() == null) {
-      throw new PatchListNotAvailableException("revision is null for " + patchSet.getId());
-    }
-    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
+    ObjectId b = patchSet.commitId();
     Whitespace ws = Whitespace.IGNORE_NONE;
     if (parentNum != null) {
       return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index 6b1a153..7aa47c599 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -229,7 +229,7 @@
   }
 
   Patch toPatch(PatchSet.Id setId) {
-    final Patch p = new Patch(new Patch.Key(setId, getNewName()));
+    final Patch p = new Patch(Patch.key(setId, getNewName()));
     p.setChangeType(getChangeType());
     p.setPatchType(getPatchType());
     p.setSourceFileName(getOldName());
diff --git a/java/com/google/gerrit/server/patch/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
index 2df6d66..bf38029 100644
--- a/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
@@ -82,7 +83,7 @@
   private transient Whitespace whitespace;
 
   private PatchListKey(AnyObjectId a, AnyObjectId b, Whitespace ws) {
-    oldId = a != null ? a.copy() : null;
+    oldId = ObjectIds.copyOrNull(a);
     newId = b.copy();
     whitespace = ws;
   }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 08ee7a3..ec05200 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -136,7 +137,7 @@
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
-    changeId = patchSetB.getParentKey();
+    changeId = patchSetB.changeId();
   }
 
   @AssistedInject
@@ -172,7 +173,7 @@
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
-    changeId = patchSetB.getParentKey();
+    changeId = patchSetB.changeId();
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
   }
 
@@ -188,19 +189,28 @@
   public PatchScript call()
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
-    if (parentNum < 0) {
-      validatePatchSetId(psa);
-    }
+    validatePatchSetId(psa);
     validatePatchSetId(psb);
 
-    PatchSet psEntityA = psa != null ? psUtil.get(notes, psa) : null;
-    PatchSet psEntityB = psb.get() == 0 ? new PatchSet(psb) : psUtil.get(notes, psb);
-    if (psEntityA != null || psEntityB != null) {
-      try {
-        permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-      } catch (AuthException e) {
-        throw new NoSuchChangeException(changeId);
-      }
+    if (psa != null) {
+      checkState(parentNum < 0, "expected no parentNum when psa is present");
+      checkArgument(psa.get() != 0, "edit not supported for left side");
+      aId = getCommitId(psa);
+    } else {
+      aId = null;
+    }
+
+    if (psb.get() != 0) {
+      bId = getCommitId(psb);
+    } else {
+      // Change edit: create synthetic PatchSet corresponding to the edit.
+      bId = getEditRev();
+    }
+
+    try {
+      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
@@ -208,11 +218,6 @@
     }
 
     try (Repository git = repoManager.openRepository(notes.getProjectName())) {
-      bId = toObjectId(psEntityB);
-      if (parentNum < 0) {
-        aId = psEntityA != null ? toObjectId(psEntityA) : null;
-      }
-
       try {
         final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
         final PatchScriptBuilder b = newBuilder(list, git);
@@ -258,20 +263,12 @@
     return b;
   }
 
-  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException {
-    if (ps.getId().get() == 0) {
-      return getEditRev();
+  private ObjectId getCommitId(PatchSet.Id psId) {
+    PatchSet ps = psUtil.get(notes, psId);
+    if (ps == null) {
+      throw new NoSuchChangeException(psId.changeId());
     }
-    if (ps.getRevision() == null || ps.getRevision().get() == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      logger.atSevere().log("Patch set %s has invalid revision", ps.getId());
-      throw new NoSuchChangeException(changeId, e);
-    }
+    return ps.commitId();
   }
 
   private ObjectId getEditRev() throws AuthException, IOException {
@@ -284,7 +281,7 @@
 
   private void validatePatchSetId(PatchSet.Id psId) throws NoSuchChangeException {
     if (psId == null) { // OK, means use base;
-    } else if (changeId.equals(psId.getParentKey())) { // OK, same change;
+    } else if (changeId.equals(psId.changeId())) { // OK, same change;
     } else {
       throw new NoSuchChangeException(changeId);
     }
@@ -306,7 +303,7 @@
           switch (changeType) {
             case COPIED:
             case RENAMED:
-              if (ps.getId().equals(psa)) {
+              if (ps.id().equals(psa)) {
                 name = oldName;
               }
               break;
@@ -319,12 +316,12 @@
           }
         }
 
-        Patch p = new Patch(new Patch.Key(ps.getId(), name));
+        Patch p = new Patch(Patch.key(ps.id(), name));
         history.add(p);
         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(Patch.key(PatchSet.id(psb.changeId(), 0), fileName));
         history.add(p);
         byKey.put(p.getKey(), p);
       }
@@ -385,8 +382,8 @@
   private void loadPublished(Map<Patch.Key, Patch> byKey, String file) {
     for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
       comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = Patch.key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
         p.setCommentCount(p.getCommentCount() + 1);
@@ -397,8 +394,8 @@
   private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file) {
     for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
       comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = Patch.key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
         p.setDraftCount(p.getDraftCount() + 1);
diff --git a/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java b/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
index 0eb5588..c684da5 100644
--- a/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
@@ -19,7 +19,6 @@
 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.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.Emails;
@@ -33,7 +32,6 @@
 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;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -60,7 +58,7 @@
     info.setMessage(src.getFullMessage());
     info.setAuthor(toUserIdentity(src.getAuthorIdent()));
     info.setCommitter(toUserIdentity(src.getCommitterIdent()));
-    info.setRevId(src.getName());
+    info.setCommitId(src);
     return info;
   }
 
@@ -78,8 +76,8 @@
       throws PatchSetInfoNotAvailableException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      final RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
-      PatchSetInfo info = get(rw, src, patchSet.getId());
+      RevCommit src = rw.parseCommit(patchSet.commitId());
+      PatchSetInfo info = get(rw, src, patchSet.id());
       info.setParents(toParentInfos(src.getParents(), rw));
       return info;
     } catch (IOException | StorageException e) {
@@ -111,9 +109,8 @@
     List<PatchSetInfo.ParentInfo> pInfos = new ArrayList<>(parents.length);
     for (RevCommit parent : parents) {
       walk.parseBody(parent);
-      RevId rev = new RevId(parent.getId().name());
       String msg = parent.getShortMessage();
-      pInfos.add(new PatchSetInfo.ParentInfo(rev, msg));
+      pInfos.add(new PatchSetInfo.ParentInfo(parent, msg));
     }
     return pInfos;
   }
diff --git a/java/com/google/gerrit/server/patch/Text.java b/java/com/google/gerrit/server/patch/Text.java
index 172dbaf..f127f44 100644
--- a/java/com/google/gerrit/server/patch/Text.java
+++ b/java/com/google/gerrit/server/patch/Text.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
@@ -62,7 +63,7 @@
             RevCommit p = c.getParent(0);
             rw.parseBody(p);
             b.append("Parent:     ");
-            b.append(reader.abbreviate(p, 8).name());
+            b.append(abbreviateName(p, reader));
             b.append(" (");
             b.append(p.getShortMessage());
             b.append(")\n");
@@ -73,7 +74,7 @@
             RevCommit p = c.getParent(i);
             rw.parseBody(p);
             b.append(i == 0 ? "Merge Of:   " : "            ");
-            b.append(reader.abbreviate(p, 8).name());
+            b.append(abbreviateName(p, reader));
             b.append(" (");
             b.append(p.getShortMessage());
             b.append(")\n");
@@ -106,7 +107,7 @@
           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(abbreviateName(commit, reader));
             b.append(" ");
             b.append(commit.getShortMessage());
             b.append("\n");
@@ -116,6 +117,10 @@
     }
   }
 
+  private static String abbreviateName(RevCommit p, ObjectReader reader) throws IOException {
+    return ObjectIds.abbreviateName(p, 8, reader);
+  }
+
   private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
     if (person != null) {
       b.append(field).append(":    ");
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index a5174e1..6f9f75a 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
@@ -38,7 +37,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -89,7 +88,7 @@
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
 
-  private Map<Change.Id, Branch.NameKey> visibleChanges;
+  private Map<Change.Id, BranchNameKey> visibleChanges;
 
   @Inject
   DefaultRefFilter(
@@ -365,7 +364,7 @@
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChanges.get(id).get())
+            .ref(visibleChanges.get(id).branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
         return true;
       } catch (AuthException e) {
@@ -375,11 +374,10 @@
     return false;
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch()
-      throws PermissionBackendException {
+  private Map<Change.Id, BranchNameKey> visibleChangesBySearch() throws PermissionBackendException {
     Project.NameKey project = projectState.getNameKey();
     try {
-      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
+      Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(project)) {
         ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
         if (!projectState.statePermitsRead()) {
@@ -400,7 +398,7 @@
     }
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesByScan(Repository repo)
+  private Map<Change.Id, BranchNameKey> visibleChangesByScan(Repository repo)
       throws PermissionBackendException {
     Project.NameKey p = projectState.getNameKey();
     ImmutableList<ChangeNotesResult> changes;
@@ -412,7 +410,7 @@
       return Collections.emptyMap();
     }
 
-    Map<Change.Id, Branch.NameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
+    Map<Change.Id, BranchNameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
     for (ChangeNotesResult notesResult : changes) {
       ChangeNotes notes = toNotes(notesResult);
       if (notes != null) {
@@ -499,7 +497,7 @@
     // even if the change is not part of the set of most recent changes that
     // SearchingChangeCacheImpl returns.
     Change.Id cId = Change.Id.fromRef(refName);
-    checkNotNull(cId, "invalid change id for ref %s", refName);
+    requireNonNull(cId, () -> String.format("invalid change id for ref %s", refName));
     ChangeNotes notes;
     try {
       notes = changeNotesFactory.create(projectState.getNameKey(), cId);
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 3d10181..119d414 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -157,8 +157,8 @@
     public abstract ForProject project(Project.NameKey project);
 
     /** Returns an instance scoped for the {@code ref}, and its parent project. */
-    public ForRef ref(Branch.NameKey ref) {
-      return project(ref.getParentKey()).ref(ref.get());
+    public ForRef ref(BranchNameKey ref) {
+      return project(ref.project()).ref(ref.branch());
     }
 
     /** Returns an instance scoped for the change, and its destination ref and project. */
@@ -280,7 +280,7 @@
     /** Returns an instance scoped for the change, and its destination ref and project. */
     public ForChange change(ChangeData cd) {
       try {
-        return ref(cd.change().getDest().get()).change(cd);
+        return ref(cd.change().getDest().branch()).change(cd);
       } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
@@ -288,7 +288,7 @@
 
     /** Returns an instance scoped for the change, and its destination ref and project. */
     public ForChange change(ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).change(notes);
+      return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
     /**
@@ -297,7 +297,7 @@
      * stale data from the index is acceptable.
      */
     public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).indexedChange(cd, notes);
+      return ref(notes.getChange().getDest().branch()).indexedChange(cd, notes);
     }
 
     /** Verify scoped user can {@code perm}, throwing if denied. */
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index e370151..bc00b88 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -108,8 +108,8 @@
     return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
   }
 
-  RefControl controlForRef(Branch.NameKey ref) {
-    return controlForRef(ref.get());
+  RefControl controlForRef(BranchNameKey ref) {
+    return controlForRef(ref.branch());
   }
 
   public RefControl controlForRef(String refName) {
diff --git a/java/com/google/gerrit/server/project/BranchResource.java b/java/com/google/gerrit/server/project/BranchResource.java
index 622b1dd..40403b4 100644
--- a/java/com/google/gerrit/server/project/BranchResource.java
+++ b/java/com/google/gerrit/server/project/BranchResource.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.Ref;
@@ -33,8 +33,8 @@
     this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
   }
 
-  public Branch.NameKey getBranchKey() {
-    return new Branch.NameKey(getNameKey(), refName);
+  public BranchNameKey getBranchKey() {
+    return BranchNameKey.create(getNameKey(), refName);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index b33fcb5..f4a3203 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -117,7 +117,7 @@
         if ((rule.getAction() == Action.ALLOW)
             && (rule.getGroup() != null)
             && (rule.getGroup().getUUID() != null)) {
-          groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
+          groupIds.add(AccountGroup.uuid(rule.getGroup().getUUID().get()));
         }
       }
     }
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index df31c19..7405df1 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -61,7 +61,7 @@
   }
 
   public void setProjectName(String n) {
-    projectName = n != null ? new Project.NameKey(n) : null;
+    projectName = n != null ? Project.nameKey(n) : null;
   }
 
   public void setProjectName(Project.NameKey n) {
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index 34f3c33..21be8e3 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -17,7 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 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.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -64,15 +64,12 @@
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void checkCreateRef(
-      Provider<? extends CurrentUser> user,
-      Repository repo,
-      Branch.NameKey branch,
-      RevObject object)
+      Provider<? extends CurrentUser> user, Repository repo, BranchNameKey branch, RevObject object)
       throws AuthException, PermissionBackendException, NoSuchProjectException, IOException,
           ResourceConflictException {
-    ProjectState ps = projectCache.checkedGet(branch.getParentKey());
+    ProjectState ps = projectCache.checkedGet(branch.project());
     if (ps == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
+      throw new NoSuchProjectException(branch.project());
     }
     ps.checkStatePermitsWrite();
 
@@ -85,8 +82,7 @@
       try (RevWalk rw = new RevWalk(repo)) {
         rw.parseBody(tag);
       } catch (IOException e) {
-        logger.atSevere().withCause(e).log(
-            "RevWalk(%s) parsing %s:", branch.getParentKey(), tag.name());
+        logger.atSevere().withCause(e).log("RevWalk(%s) parsing %s:", branch.project(), tag.name());
         throw e;
       }
 
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index fdb8740..23eb9a8 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -48,7 +48,7 @@
         logger.atWarning().log("null field in group list for %s:\n%s", project, text);
         continue;
       }
-      AccountGroup.UUID uuid = new AccountGroup.UUID(row.left);
+      AccountGroup.UUID uuid = AccountGroup.uuid(row.left);
       String name = row.right;
       GroupReference ref = new GroupReference(uuid, name);
 
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index c7858dd..509caa4 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -57,7 +57,7 @@
    *     errors.
    * @return the cached data or null when strict = false
    */
-  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
+  ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
 
   /**
    * Invalidate the cached information about the given project, and triggers reindexing for it
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 4a85554..3542187 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -262,8 +262,8 @@
 
   @Override
   public ImmutableSortedSet<Project.NameKey> byName(String pfx) {
-    Project.NameKey start = new Project.NameKey(pfx);
-    Project.NameKey end = new Project.NameKey(pfx + Character.MAX_VALUE);
+    Project.NameKey start = Project.nameKey(pfx);
+    Project.NameKey end = Project.nameKey(pfx + Character.MAX_VALUE);
     try {
       // Right endpoint is exclusive, but U+FFFF is a non-character so no project ends with it.
       return list.get(ListKey.ALL).subSet(start, end);
@@ -295,7 +295,7 @@
     public ProjectState load(String projectName) throws Exception {
       try (TraceTimer timer = TraceContext.newTimer("Loading project %s", projectName)) {
         long now = clock.read();
-        Project.NameKey key = new Project.NameKey(projectName);
+        Project.NameKey key = Project.nameKey(projectName);
         try (Repository git = mgr.openRepository(key)) {
           ProjectConfig cfg = projectConfigFactory.create(key);
           cfg.load(key, git);
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 3700556..e29a48a 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -48,7 +48,7 @@
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.GroupBackend;
@@ -341,7 +341,7 @@
     return subscribeSections;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (SubscribeSection s : subscribeSections.values()) {
       if (s.appliesTo(branch)) {
@@ -709,7 +709,7 @@
         if (groupName != null) {
           GroupReference ref = groupsByName.get(groupName);
           if (ref == null) {
-            ref = new GroupReference(null, groupName);
+            ref = new GroupReference(groupName);
             groupsByName.put(ref.getName(), ref);
           }
           if (ref.getUUID() != null) {
@@ -1034,7 +1034,7 @@
     subscribeSections = new HashMap<>();
     try {
       for (String projectName : subsections) {
-        Project.NameKey p = new Project.NameKey(projectName);
+        Project.NameKey p = Project.nameKey(projectName);
         SubscribeSection ss = new SubscribeSection(p);
         for (String s :
             rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f2a93d3..ca6c9f4 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toMap;
 
 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.LabelValue;
 import com.google.gerrit.extensions.common.LabelTypeInfo;
@@ -29,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.HashMap;
-import java.util.List;
 
 @Singleton
 public class ProjectJson {
@@ -65,7 +65,7 @@
     info.description = Strings.emptyToNull(p.getDescription());
     info.state = p.getState();
     info.id = Url.encode(info.name);
-    List<WebLinkInfo> links = webLinks.getProjectLinks(p.getName());
+    ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(p.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 921d382..22f4227 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -39,7 +39,7 @@
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.CapabilityCollection;
@@ -67,7 +67,10 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-/** Cached information on a project. */
+/**
+ * Cached information on a project. Must not contain any data derived from parents other than it's
+ * immediate parent's {@link com.google.gerrit.reviewdb.client.Project.NameKey}.
+ */
 public class ProjectState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -100,9 +103,6 @@
   /** If this is all projects, the capabilities used by the server. */
   private final CapabilityCollection capabilities;
 
-  /** All label types applicable to changes in this project. */
-  private LabelTypes labelTypes;
-
   @Inject
   public ProjectState(
       ProjectCache projectCache,
@@ -451,10 +451,23 @@
 
   /** All available label types. */
   public LabelTypes getLabelTypes() {
-    if (labelTypes == null) {
-      labelTypes = loadLabelTypes();
+    Map<String, LabelType> types = new LinkedHashMap<>();
+    for (ProjectState s : treeInOrder()) {
+      for (LabelType type : s.getConfig().getLabelSections().values()) {
+        String lower = type.getName().toLowerCase();
+        LabelType old = types.get(lower);
+        if (old == null || old.canOverride()) {
+          types.put(lower, type);
+        }
+      }
     }
-    return labelTypes;
+    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
+    for (LabelType type : types.values()) {
+      if (!type.getValues().isEmpty()) {
+        all.add(type);
+      }
+    }
+    return new LabelTypes(Collections.unmodifiableList(all));
   }
 
   /** All available label types for this change. */
@@ -463,7 +476,7 @@
   }
 
   /** All available label types for this branch. */
-  public LabelTypes getLabelTypes(Branch.NameKey destination) {
+  public LabelTypes getLabelTypes(BranchNameKey destination) {
     List<LabelType> all = getLabelTypes().getLabelTypes();
 
     List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
@@ -524,7 +537,7 @@
     return null;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (ProjectState s : tree()) {
       ret.addAll(s.getConfig().getSubscribeSections(branch));
@@ -571,27 +584,7 @@
     return project;
   }
 
-  private LabelTypes loadLabelTypes() {
-    Map<String, LabelType> types = new LinkedHashMap<>();
-    for (ProjectState s : treeInOrder()) {
-      for (LabelType type : s.getConfig().getLabelSections().values()) {
-        String lower = type.getName().toLowerCase();
-        LabelType old = types.get(lower);
-        if (old == null || old.canOverride()) {
-          types.put(lower, type);
-        }
-      }
-    }
-    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
-    for (LabelType type : types.values()) {
-      if (!type.getValues().isEmpty()) {
-        all.add(type);
-      }
-    }
-    return new LabelTypes(Collections.unmodifiableList(all));
-  }
-
-  private boolean match(Branch.NameKey destination, String refPattern) {
-    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), null);
+  private boolean match(BranchNameKey destination, String refPattern) {
+    return RefPatternMatcher.getMatcher(refPattern).match(destination.branch(), null);
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 32dbe1c..83393bc 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeJson;
@@ -225,7 +226,7 @@
               // important thing for callers is that auto-closable changes are closed. Which of the
               // commits is used to auto-close a change if there are several candidates is of minor
               // importance and hence can be non-deterministic.
-              Change.Key changeKey = new Change.Key(changeId);
+              Change.Key changeKey = Change.key(changeId);
               if (!changeIdToMergedSha1.containsKey(changeKey)) {
                 changeIdToMergedSha1.put(changeKey, commitId);
               }
@@ -296,7 +297,7 @@
                 // Auto-close by commit
                 for (ObjectId patchSetSha1 :
                     autoCloseableChange.patchSets().stream()
-                        .map(ps -> ObjectId.fromString(ps.getRevision().get()))
+                        .map(PatchSet::commitId)
                         .collect(toSet())) {
                   if (mergedSha1s.contains(patchSetSha1)) {
                     autoCloseableChangesByBranch.add(
diff --git a/java/com/google/gerrit/server/project/RefFilter.java b/java/com/google/gerrit/server/project/RefFilter.java
index 76bafc0..cdabcbe 100644
--- a/java/com/google/gerrit/server/project/RefFilter.java
+++ b/java/com/google/gerrit/server/project/RefFilter.java
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Predicate;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 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;
+import java.util.stream.Stream;
 
 public class RefFilter<T extends RefInfo> {
   private final String prefix;
@@ -55,15 +57,17 @@
     return this;
   }
 
-  public List<T> filter(List<T> refs) throws BadRequestException {
+  public ImmutableList<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);
+    Stream<T> results = refs.stream();
     if (!Strings.isNullOrEmpty(matchSubstring)) {
-      results = results.filter(new SubstringPredicate(matchSubstring));
+      String lowercaseSubstring = matchSubstring.toLowerCase(Locale.US);
+      results = results.filter(refInfo -> matchesSubstring(prefix, lowercaseSubstring, refInfo));
     } else if (!Strings.isNullOrEmpty(matchRegex)) {
-      results = results.filter(new RegexPredicate(matchRegex));
+      RunAutomaton a = parseRegex(matchRegex);
+      results = results.filter(refInfo -> matchesRegex(prefix, a, refInfo));
     }
     if (start > 0) {
       results = results.skip(start);
@@ -71,51 +75,39 @@
     if (limit > 0) {
       results = results.limit(limit);
     }
-    return results.toList();
+    return results.collect(toImmutableList());
   }
 
-  private class SubstringPredicate implements Predicate<T> {
-    private final String substring;
-
-    private SubstringPredicate(String substring) {
-      this.substring = substring.toLowerCase(Locale.US);
+  private static <T extends RefInfo> boolean matchesSubstring(
+      String prefix, String lowercaseSubstring, T refInfo) {
+    String ref = refInfo.ref;
+    if (ref.startsWith(prefix)) {
+      ref = ref.substring(prefix.length());
     }
+    ref = ref.toLowerCase(Locale.US);
+    return ref.contains(lowercaseSubstring);
+  }
 
-    @Override
-    public boolean apply(T in) {
-      String ref = in.ref;
-      if (ref.startsWith(prefix)) {
-        ref = ref.substring(prefix.length());
+  private static RunAutomaton parseRegex(String regex) throws BadRequestException {
+    if (regex.startsWith("^")) {
+      regex = regex.substring(1);
+      if (regex.endsWith("$") && !regex.endsWith("\\$")) {
+        regex = regex.substring(0, regex.length() - 1);
       }
-      ref = ref.toLowerCase(Locale.US);
-      return ref.contains(substring);
+    }
+    try {
+      return new RunAutomaton(new RegExp(regex).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
     }
   }
 
-  private class RegexPredicate implements Predicate<T> {
-    private final RunAutomaton a;
-
-    private RegexPredicate(String regex) throws BadRequestException {
-      if (regex.startsWith("^")) {
-        regex = regex.substring(1);
-        if (regex.endsWith("$") && !regex.endsWith("\\$")) {
-          regex = regex.substring(0, regex.length() - 1);
-        }
-      }
-      try {
-        a = new RunAutomaton(new RegExp(regex).toAutomaton());
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException(e.getMessage());
-      }
+  private static <T extends RefInfo> boolean matchesRegex(
+      String prefix, RunAutomaton a, T refInfo) {
+    String ref = refInfo.ref;
+    if (ref.startsWith(prefix)) {
+      ref = ref.substring(prefix.length());
     }
-
-    @Override
-    public boolean apply(T in) {
-      String ref = in.ref;
-      if (ref.startsWith(prefix)) {
-        ref = ref.substring(prefix.length());
-      }
-      return a.run(ref);
-    }
+    return a.run(ref);
   }
 }
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index 0a5980c..67c0d03 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -43,7 +43,7 @@
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
-            new Project(new Project.NameKey(projectName)),
+            new Project(Project.nameKey(projectName)),
             user,
             RefOperationValidators.getCommand(update, operationType));
     try {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index eeb2a65..efd99dd 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -47,7 +47,7 @@
   public void checkRemoveReviewer(
       ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
       throws PermissionBackendException, AuthException {
-    checkRemoveReviewer(notes, currentUser, approval.getAccountId(), approval.getValue());
+    checkRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
   }
 
   /**
@@ -108,7 +108,7 @@
     // owner and site admin can remove anyone
     PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
     PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (check(forProject.ref(change.getDest().get()), RefPermission.WRITE_CONFIG)
+    if (check(forProject.ref(change.getDest().branch()), RefPermission.WRITE_CONFIG)
         || check(withUser, GlobalPermission.ADMINISTRATE_SERVER)) {
       return true;
     }
diff --git a/java/com/google/gerrit/server/project/testing/Util.java b/java/com/google/gerrit/server/project/testing/Util.java
index 204fa7b..2bd71c3 100644
--- a/java/com/google/gerrit/server/project/testing/Util.java
+++ b/java/com/google/gerrit/server/project/testing/Util.java
@@ -28,8 +28,8 @@
 import java.util.Arrays;
 
 public class Util {
-  public static final AccountGroup.UUID ADMIN = new AccountGroup.UUID("test.admin");
-  public static final AccountGroup.UUID DEVS = new AccountGroup.UUID("test.devs");
+  public static final AccountGroup.UUID ADMIN = AccountGroup.uuid("test.admin");
+  public static final AccountGroup.UUID DEVS = AccountGroup.uuid("test.devs");
 
   public static final LabelType codeReview() {
     return category(
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index cb96bc5..55b3eda 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -44,7 +44,7 @@
     List<Predicate<AccountState>> preds = Lists.newArrayListWithCapacity(3);
     Integer id = Ints.tryParse(query);
     if (id != null) {
-      preds.add(id(new Account.Id(id)));
+      preds.add(id(Account.id(id)));
     }
     if (canSeeSecondaryEmails) {
       preds.add(equalsNameIncludingSecondaryEmails(query));
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 619569f..d3b57d7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -74,6 +74,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -222,12 +223,18 @@
    * @return instance for testing.
    */
   public static ChangeData createForTest(
-      Project.NameKey project, Change.Id id, int currentPatchSetId) {
+      Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
             null, project, id, null, null);
-    cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
+    cd.currentPatchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(id, currentPatchSetId))
+            .commitId(commitId)
+            .uploader(Account.id(1000))
+            .createdOn(TimeUtil.nowTs())
+            .build();
     return cd;
   }
 
@@ -387,7 +394,7 @@
         return Optional.empty();
       }
 
-      ObjectId id = ObjectId.fromString(ps.getRevision().get());
+      ObjectId id = ps.commitId();
       Whitespace ws = Whitespace.IGNORE_NONE;
       PatchListKey pk =
           parentCount > 1
@@ -497,7 +504,7 @@
         return null;
       }
       for (PatchSet p : patchSets()) {
-        if (p.getId().equals(c.currentPatchSetId())) {
+        if (p.id().equals(c.currentPatchSetId())) {
           currentPatchSet = p;
           return p;
         }
@@ -580,10 +587,9 @@
     if (ps == null) {
       return false;
     }
-    String sha1 = ps.getRevision().get();
     try (Repository repo = repoManager.openRepository(project());
         RevWalk walk = new RevWalk(repo)) {
-      RevCommit c = walk.parseCommit(ObjectId.fromString(sha1));
+      RevCommit c = walk.parseCommit(ps.commitId());
       commitMessage = c.getFullMessage();
       commitFooters = c.getFooterLines();
       author = c.getAuthorIdent();
@@ -610,11 +616,11 @@
 
   /** @return patch with the given ID, or null if it does not exist. */
   public PatchSet patchSet(PatchSet.Id psId) {
-    if (currentPatchSet != null && currentPatchSet.getId().equals(psId)) {
+    if (currentPatchSet != null && currentPatchSet.id().equals(psId)) {
       return currentPatchSet;
     }
     for (PatchSet ps : patchSets()) {
-      if (ps.getId().equals(psId)) {
+      if (ps.id().equals(psId)) {
         return ps;
       }
     }
@@ -887,7 +893,7 @@
         }
 
         try (Repository repo = repoManager.openRepository(project())) {
-          Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
+          Ref ref = repo.getRefDatabase().exactRef(c.getDest().branch());
           SubmitTypeRecord str = submitTypeRecord();
           if (!str.isOk()) {
             // If submit type rules are broken, it's definitely not mergeable.
@@ -897,13 +903,7 @@
           String mergeStrategy =
               mergeUtilFactory.create(projectCache.get(project())).mergeStrategyName();
           mergeable =
-              mergeabilityCache.get(
-                  ObjectId.fromString(ps.getRevision().get()),
-                  ref,
-                  str.type,
-                  mergeStrategy,
-                  c.getDest(),
-                  repo);
+              mergeabilityCache.get(ps.commitId(), ref, str.type, mergeStrategy, c.getDest(), repo);
         } catch (IOException e) {
           throw new StorageException(e);
         }
@@ -981,11 +981,11 @@
 
     PatchSet ps = currentPatchSet();
     if (ps != null) {
-      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.number())) {
         return true;
       }
 
-      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.number())) {
         return false;
       }
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 93e1a95..aa167bb 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -45,7 +45,7 @@
 import com.google.gerrit.mail.Address;
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.AnonymousUser;
@@ -192,7 +192,7 @@
   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);
+  public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
 
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
@@ -461,13 +461,13 @@
     if (triplet.isPresent()) {
       return Predicate.and(
           project(triplet.get().project().get()),
-          branch(triplet.get().branch().get()),
+          branch(triplet.get().branch().branch()),
           new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
     }
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return new LegacyChangeIdPredicate(new Change.Id(id));
+        return new LegacyChangeIdPredicate(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return new ChangeIdPredicate(parseChangeId(query));
@@ -575,11 +575,11 @@
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
-      return Predicate.not(new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE)));
+      return Predicate.not(new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE)));
     }
 
     if ("unassigned".equalsIgnoreCase(value)) {
-      return new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE));
+      return new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
     if ("submittable".equalsIgnoreCase(value)) {
@@ -1197,7 +1197,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
       d.load(args.allUsersName, git);
-      Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
+      Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, name);
       }
diff --git a/java/com/google/gerrit/server/query/change/CommitPredicate.java b/java/com/google/gerrit/server/query/change/CommitPredicate.java
index 567f58d..25d3ec3 100644
--- a/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.git.ObjectIds.matchesAbbreviation;
 import static com.google.gerrit.server.index.change.ChangeField.COMMIT;
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
-import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
 
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.reviewdb.client.PatchSet;
 
 public class CommitPredicate extends ChangeIndexPredicate {
   static FieldDef<ChangeData, ?> commitField(String id) {
-    if (id.length() == OBJECT_ID_STRING_LENGTH) {
+    if (id.length() == ObjectIds.STR_LEN) {
       return EXACT_COMMIT;
     }
     return COMMIT;
@@ -45,9 +46,10 @@
   }
 
   protected 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));
+    if (getField() == EXACT_COMMIT) {
+      return p.commitId().name().equals(id);
+    }
+    return matchesAbbreviation(p.commitId(), id);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index d415f71..f18a5a7 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -86,7 +86,7 @@
 
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(new ProjectPredicate(c.getProject().get()));
-    and.add(new RefPredicate(c.getDest().get()));
+    and.add(new RefPredicate(c.getDest().branch()));
     and.add(Predicate.not(new LegacyChangeIdPredicate(c.getId())));
     and.add(Predicate.or(filePredicates));
 
@@ -97,7 +97,7 @@
 
   private static final class CheckConflict extends PostFilterPredicate<ChangeData> {
     private final Arguments args;
-    private final Branch.NameKey dest;
+    private final BranchNameKey dest;
     private final ChangeDataCache changeDataCache;
 
     CheckConflict(String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
@@ -131,7 +131,7 @@
           return false;
         }
 
-        other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
+        other = object.currentPatchSet().commitId();
         ConflictKey conflictsKey =
             ConflictKey.create(
                 changeDataCache.getTestAgainst(),
@@ -207,7 +207,7 @@
 
     ObjectId getTestAgainst() {
       if (testAgainst == null) {
-        testAgainst = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
+        testAgainst = cd.currentPatchSet().commitId();
       }
       return testAgainst;
     }
diff --git a/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 702745e..bd07914 100644
--- a/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import java.util.Set;
 
 public class DestinationPredicate extends PostFilterPredicate<ChangeData> {
-  protected Set<Branch.NameKey> destinations;
+  protected Set<BranchNameKey> destinations;
 
-  public DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
+  public DestinationPredicate(Set<BranchNameKey> destinations, String value) {
     super(ChangeQueryBuilder.FIELD_DESTINATION, value);
     this.destinations = destinations;
   }
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index acf2e25..0e07a18 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -60,7 +60,7 @@
       return false;
     }
 
-    ProjectState project = projectCache.get(c.getDest().getParentKey());
+    ProjectState project = projectCache.get(c.getDest().project());
     if (project == null) {
       // The project has disappeared.
       //
@@ -76,7 +76,7 @@
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
-        if (match(object, p.getValue(), p.getAccountId())) {
+        if (match(object, p.value(), p.accountId())) {
           return true;
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 7f7bcff..0e6f45d 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -26,7 +26,7 @@
   @Override
   public boolean match(ChangeData cd) {
     for (PatchSet ps : cd.patchSets()) {
-      List<String> groups = ps.getGroups();
+      List<String> groups = ps.groups();
       if (groups != null && groups.contains(getValue())) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 74a0f71..b364e98 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -55,8 +55,8 @@
  * holding on to a single instance.
  */
 public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChangeQuery> {
-  private static Predicate<ChangeData> ref(Branch.NameKey branch) {
-    return new RefPredicate(branch.get());
+  private static Predicate<ChangeData> ref(BranchNameKey branch) {
+    return new RefPredicate(branch.branch());
   }
 
   private static Predicate<ChangeData> change(Change.Key key) {
@@ -110,37 +110,37 @@
     return query(or(preds));
   }
 
-  public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) {
+  public List<ChangeData> byBranchKey(BranchNameKey branch, Change.Key key) {
     return query(byBranchKeyPred(branch, key));
   }
 
   public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
-    return query(and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open()));
+    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
   }
 
   public static Predicate<ChangeData> byBranchKeyOpenPred(
       Project.NameKey project, String branch, Change.Key key) {
-    return and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open());
+    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
   }
 
-  private static Predicate<ChangeData> byBranchKeyPred(Branch.NameKey branch, Change.Key key) {
-    return and(ref(branch), project(branch.getParentKey()), change(key));
+  private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
+    return and(ref(branch), project(branch.project()), change(key));
   }
 
   public List<ChangeData> byProject(Project.NameKey project) {
     return query(project(project));
   }
 
-  public List<ChangeData> byBranchOpen(Branch.NameKey branch) {
-    return query(and(ref(branch), project(branch.getParentKey()), open()));
+  public List<ChangeData> byBranchOpen(BranchNameKey branch) {
+    return query(and(ref(branch), project(branch.project()), open()));
   }
 
-  public List<ChangeData> byBranchNew(Branch.NameKey branch) {
-    return query(and(ref(branch), project(branch.getParentKey()), status(Change.Status.NEW)));
+  public List<ChangeData> byBranchNew(BranchNameKey branch) {
+    return query(and(ref(branch), project(branch.project()), status(Change.Status.NEW)));
   }
 
   public Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo, Branch.NameKey branch, Collection<String> hashes) throws IOException {
+      Repository repo, BranchNameKey branch, Collection<String> hashes) throws IOException {
     return byCommitsOnBranchNotMerged(
         repo,
         branch,
@@ -151,7 +151,7 @@
 
   @VisibleForTesting
   Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo, Branch.NameKey branch, Collection<String> hashes, int indexLimit)
+      Repository repo, BranchNameKey branch, Collection<String> hashes, int indexLimit)
       throws IOException {
     if (hashes.size() > indexLimit) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, branch, hashes);
@@ -160,7 +160,7 @@
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
-      Repository repo, Branch.NameKey branch, Collection<String> hashes) throws IOException {
+      Repository repo, BranchNameKey branch, Collection<String> hashes) throws IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
     for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
@@ -180,7 +180,7 @@
 
     List<ChangeNotes> notes =
         notesFactory.create(
-            branch.getParentKey(),
+            branch.project(),
             changeIds,
             cn -> {
               Change c = cn.getChange();
@@ -190,11 +190,11 @@
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
-      Branch.NameKey branch, Collection<String> hashes) {
+      BranchNameKey branch, Collection<String> hashes) {
     return query(
         and(
             ref(branch),
-            project(branch.getParentKey()),
+            project(branch.project()),
             not(status(Change.Status.MERGED)),
             or(commits(hashes))));
   }
@@ -241,8 +241,8 @@
     return query(byBranchCommitPred(project, branch, hash));
   }
 
-  public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) {
-    return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
+  public List<ChangeData> byBranchCommit(BranchNameKey branch, String hash) {
+    return byBranchCommit(branch.project().get(), branch.branch(), hash);
   }
 
   public List<ChangeData> byBranchCommitOpen(String project, String branch, String hash) {
diff --git a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index ddb6f32..1b3029f 100644
--- a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -25,7 +25,7 @@
 import java.util.Set;
 
 public class IsReviewedPredicate extends ChangeIndexPredicate {
-  protected static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
+  protected static final Account.Id NOT_REVIEWED = Account.id(ChangeField.NOT_REVIEWED);
 
   public static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 17d6448..ec411ee 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -40,7 +40,7 @@
 
   protected static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache, ChildProjects childProjects, String value) {
-    ProjectState projectState = projectCache.get(new Project.NameKey(value));
+    ProjectState projectState = projectCache.get(Project.nameKey(value));
     if (projectState == null) {
       return Collections.emptyList();
     }
diff --git a/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index febd6c6..c1cc999 100644
--- a/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -24,7 +24,7 @@
   }
 
   protected Project.NameKey getValueKey() {
-    return new Project.NameKey(getValue());
+    return Project.nameKey(getValue());
   }
 
   @Override
@@ -34,7 +34,7 @@
       return false;
     }
 
-    Project.NameKey p = change.getDest().getParentKey();
+    Project.NameKey p = change.getDest().project();
     return p.equals(getValueKey());
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index 744f4d2..b337336 100644
--- a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -25,7 +25,7 @@
   @Override
   public boolean match(ChangeData object) {
     Change c = object.change();
-    return c != null && c.getDest().getParentKey().get().startsWith(getValue());
+    return c != null && c.getDest().project().get().startsWith(getValue());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RefPredicate.java b/java/com/google/gerrit/server/query/change/RefPredicate.java
index 2ed4c99..10eea71 100644
--- a/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -28,7 +28,7 @@
     if (change == null) {
       return false;
     }
-    return getValue().equals(change.getDest().get());
+    return getValue().equals(change.getDest().branch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 46a17f6..a859b32 100644
--- a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -44,7 +44,7 @@
       return false;
     }
 
-    Project.NameKey p = change.getDest().getParentKey();
+    Project.NameKey p = change.getDest().project();
     return pattern.run(p.get());
   }
 
diff --git a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index af211e6..f999cc4 100644
--- a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -42,7 +42,7 @@
     if (change == null) {
       return false;
     }
-    return pattern.run(change.getDest().get());
+    return pattern.run(change.getDest().branch());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 215e36f..2e9bc4b 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -75,7 +75,7 @@
 
   @Operator
   public Predicate<InternalGroup> uuid(String uuid) {
-    return GroupPredicates.uuid(new AccountGroup.UUID(uuid));
+    return GroupPredicates.uuid(AccountGroup.uuid(uuid));
   }
 
   @Operator
@@ -169,7 +169,7 @@
   }
 
   private AccountGroup.UUID parseGroup(String groupNameOrUuid) throws QueryParseException {
-    Optional<InternalGroup> group = args.groupCache.get(new AccountGroup.UUID(groupNameOrUuid));
+    Optional<InternalGroup> group = args.groupCache.get(AccountGroup.uuid(groupNameOrUuid));
     if (group.isPresent()) {
       return group.get().getGroupUUID();
     }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index 570da6b..6637c6f 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -41,12 +41,12 @@
 
   @Operator
   public Predicate<ProjectData> name(String name) {
-    return ProjectPredicates.name(new Project.NameKey(name));
+    return ProjectPredicates.name(Project.nameKey(name));
   }
 
   @Operator
   public Predicate<ProjectData> parent(String parentName) {
-    return ProjectPredicates.parent(new Project.NameKey(parentName));
+    return ProjectPredicates.parent(Project.nameKey(parentName));
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 3040644..1eba9b3 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -10,6 +10,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index a3e9530..74c18e1 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -52,7 +52,7 @@
           PermissionBackendException {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
-      access.put(p, getAccess.apply(new Project.NameKey(p)));
+      access.put(p, getAccess.apply(Project.nameKey(p)));
     }
     return access;
   }
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index b19994c..92937f1 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -119,7 +119,7 @@
 
     Set<AccountGroup.UUID> groups = parseGroups(input.groups);
 
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     List<ExternalId> extIds = new ArrayList<>();
 
     if (input.email != null) {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 4788301..d1b15852 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -180,8 +180,8 @@
       boolean dirty = false;
       for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
-        PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), c.key.patchSetId);
-        setCommentRevId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
+        setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
         commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
         comments.add(commentFormatter.format(c));
       }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
index 798aad1..666851b 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
@@ -77,7 +77,7 @@
                 u.deleteProjectWatches(
                     input.stream()
                         .filter(Objects::nonNull)
-                        .map(w -> ProjectWatchKey.create(new Project.NameKey(w.project), w.filter))
+                        .map(w -> ProjectWatchKey.create(Project.nameKey(w.project), w.filter))
                         .collect(toList())));
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index 231d356..dc5c2b1 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -38,7 +38,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
@@ -72,12 +71,11 @@
     Project.NameKey project = revisionResource.getProject();
     ProjectState projectState = projectCache.checkedGet(project);
     PatchSet patchSet = revisionResource.getPatchSet();
-    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
 
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
       List<TreeModification> treeModifications =
           fixReplacementInterpreter.toTreeModifications(
-              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
+              repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
       ChangeEdit changeEdit =
           changeEditModifier.combineWithModifiedPatchSetTree(
               repository, revisionResource.getNotes(), patchSet, treeModifications);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 424f4d7..f90b8e5 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -60,7 +61,6 @@
 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;
@@ -358,9 +358,7 @@
         return Response.ok(
             fileContentUtil.getContent(
                 projectCache.checkedGet(rsrc.getChangeResource().getProject()),
-                base
-                    ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
-                    : edit.getEditCommit(),
+                base ? edit.getBasePatchSet().commitId() : edit.getEditCommit(),
                 rsrc.getPath(),
                 null));
       } catch (ResourceNotFoundException | BadRequestException e) {
@@ -383,12 +381,12 @@
       FileInfo r = new FileInfo();
       ChangeEdit edit = rsrc.getChangeEdit();
       Change change = edit.getChange();
-      List<DiffWebLinkInfo> links =
+      ImmutableList<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
               change.getProject().get(),
               change.getChangeId(),
-              edit.getBasePatchSet().getPatchSetId(),
-              edit.getBasePatchSet().getRefName(),
+              edit.getBasePatchSet().number(),
+              edit.getBasePatchSet().refName(),
               rsrc.getPath(),
               0,
               edit.getRefName(),
@@ -461,9 +459,7 @@
         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(edit.get().getBasePatchSet().commitId());
             msg = commit.getFullMessage();
           }
         } else {
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
index 6ec4fdb..3ac3eed 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
@@ -39,6 +39,6 @@
   @Override
   public IncludedInInfo apply(ChangeResource rsrc) throws RestApiException, IOException {
     PatchSet ps = psUtil.current(rsrc.getNotes());
-    return includedIn.apply(rsrc.getProject(), ps.getRevision().get());
+    return includedIn.apply(rsrc.getProject(), ps.commitId().name());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPick.java b/java/com/google/gerrit/server/restapi/change/CherryPick.java
index 72781c3..3fd80df 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPick.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPick.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.RevisionResource;
@@ -103,7 +103,7 @@
               rsrc.getChange(),
               rsrc.getPatchSet(),
               input,
-              new Branch.NameKey(rsrc.getProject(), refName));
+              BranchNameKey.create(rsrc.getProject(), refName));
       CherryPickChangeInfo changeInfo =
           json.noOptions()
               .format(rsrc.getProject(), cherryPickResult.changeId(), CherryPickChangeInfo::new);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 19a9233..afd9d8e9 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -29,7 +29,7 @@
 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.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -139,16 +139,11 @@
       Change change,
       PatchSet patch,
       CherryPickInput input,
-      Branch.NameKey dest)
+      BranchNameKey dest)
       throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
           RestApiException, ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        batchUpdateFactory,
-        change,
-        change.getProject(),
-        ObjectId.fromString(patch.getRevision().get()),
-        input,
-        dest);
+        batchUpdateFactory, change, change.getProject(), patch.commitId(), input, dest);
   }
 
   public Result cherryPick(
@@ -157,7 +152,7 @@
       Project.NameKey project,
       ObjectId sourceCommit,
       CherryPickInput input,
-      Branch.NameKey dest)
+      BranchNameKey dest)
       throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
           RestApiException, ConfigInvalidException, NoSuchProjectException {
 
@@ -169,10 +164,10 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      Ref destRef = git.getRefDatabase().exactRef(dest.branch());
       if (destRef == null) {
         throw new InvalidChangeOperationException(
-            String.format("Branch %s does not exist.", dest.get()));
+            String.format("Branch %s does not exist.", dest.branch()));
       }
 
       RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
@@ -200,9 +195,9 @@
       String commitMessage = ChangeIdUtil.insertId(input.message, computedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
-      ProjectState projectState = projectCache.checkedGet(dest.getParentKey());
+      ProjectState projectState = projectCache.checkedGet(dest.project());
       if (projectState == null) {
-        throw new NoSuchProjectException(dest.getParentKey());
+        throw new NoSuchProjectException(dest.project());
       }
       try {
         MergeUtil mergeUtil;
@@ -230,12 +225,12 @@
         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);
+          changeKey = Change.key(idStr);
         } else {
-          changeKey = new Change.Key("I" + computedChangeId.name());
+          changeKey = Change.key("I" + computedChangeId.name());
         }
 
-        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
+        BranchNameKey newDest = BranchNameKey.create(project, destRef.getName());
         List<ChangeData> destChanges =
             queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
@@ -258,11 +253,17 @@
             // change.
             String newTopic = null;
             if (sourceChange != null && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-              newTopic = sourceChange.getTopic() + "-" + newDest.getShortName();
+              newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
             }
             changeId =
                 createNewChange(
-                    bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
+                    bu,
+                    cherryPickCommit,
+                    dest.branch(),
+                    newTopic,
+                    sourceChange,
+                    sourceCommit,
+                    input);
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -336,9 +337,9 @@
       ObjectId sourceCommit,
       CherryPickInput input)
       throws IOException {
-    Change.Id changeId = new Change.Id(seq.nextChangeId());
+    Change.Id changeId = Change.id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
-    Branch.NameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     ins.setMessage(
             messageForDestinationChange(
                 ins.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit))
@@ -369,12 +370,12 @@
 
   private String messageForDestinationChange(
       PatchSet.Id patchSetId,
-      Branch.NameKey sourceBranch,
+      BranchNameKey sourceBranch,
       ObjectId sourceCommit,
       CodeReviewCommit cherryPickCommit) {
     StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
     if (sourceBranch != null) {
-      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.shortName());
     } else {
       stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
index f34f178..ff5c377 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -20,7 +20,7 @@
 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.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -102,7 +102,7 @@
               projectName,
               commit,
               input,
-              new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
+              BranchNameKey.create(rsrc.getProjectState().getNameKey(), refName));
       CherryPickChangeInfo changeInfo =
           json.noOptions()
               .format(projectName, cherryPickResult.changeId(), CherryPickChangeInfo::new);
diff --git a/java/com/google/gerrit/server/restapi/change/Comments.java b/java/com/google/gerrit/server/restapi/change/Comments.java
index d9a0d57..84771b1 100644
--- a/java/com/google/gerrit/server/restapi/change/Comments.java
+++ b/java/com/google/gerrit/server/restapi/change/Comments.java
@@ -58,7 +58,7 @@
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().getId())) {
+    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
         return new CommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 2326027..9c952f7 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -277,7 +277,7 @@
       if (input.baseChange != null) {
         ChangeNotes baseChange = getBaseChange(input.baseChange);
         basePatchSet = psUtil.current(baseChange);
-        groups = basePatchSet.getGroups();
+        groups = basePatchSet.groups();
       }
       ObjectId parentCommit =
           getParentCommit(git, rw, input.branch, input.newBranch, basePatchSet, input.baseCommit);
@@ -297,7 +297,7 @@
         c = newCommit(oi, rw, author, mergeTip, commitMessage);
       }
 
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      Change.Id changeId = Change.id(seq.nextChangeId());
       ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
       ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
       ins.setTopic(input.topic);
@@ -345,7 +345,7 @@
       throws BadRequestException, IOException, UnprocessableEntityException,
           ResourceConflictException {
     if (basePatchSet != null) {
-      return ObjectId.fromString(basePatchSet.getRevision().get());
+      return basePatchSet.commitId();
     }
 
     Ref destRef = repo.getRefDatabase().exactRef(inputBranch);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index b6e7628..4ee9ba5 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -84,7 +84,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getPatchSet().getId(), in);
+      Op op = new Op(rsrc.getPatchSet().id(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.created(
@@ -115,11 +115,11 @@
 
       comment =
           commentsUtil.newComment(
-              ctx, in.path, ps.getId(), in.side(), in.message.trim(), in.unresolved, parentUuid);
+              ctx, in.path, ps.id(), in.side(), in.message.trim(), in.unresolved, parentUuid);
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
 
       commentsUtil.putComments(ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
       return true;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index d3e737f..76fbab2 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -138,7 +138,7 @@
     PatchSet ps = psUtil.current(rsrc.getNotes());
     Change change = rsrc.getChange();
     Project.NameKey project = change.getProject();
-    Branch.NameKey dest = change.getDest();
+    BranchNameKey dest = change.getDest();
     try (Repository git = gitManager.openRepository(project);
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
@@ -154,10 +154,10 @@
       List<String> groups = null;
       if (!in.inheritParent && !in.baseChange.isEmpty()) {
         PatchSet basePS = findBasePatchSet(in.baseChange);
-        currentPsCommit = rw.parseCommit(ObjectId.fromString(basePS.getRevision().get()));
-        groups = basePS.getGroups();
+        currentPsCommit = rw.parseCommit(basePS.commitId());
+        groups = basePS.groups();
       } else {
-        currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        currentPsCommit = rw.parseCommit(ps.commitId());
       }
 
       Timestamp now = TimeUtil.nowTs();
@@ -176,7 +176,7 @@
               author,
               ObjectId.fromString(change.getKey().get().substring(1)));
 
-      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
+      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.id());
       PatchSetInserter psInserter =
           patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
       try (BatchUpdate bu = updateFactory.create(project, me, now)) {
@@ -215,7 +215,7 @@
   private RevCommit createMergeCommit(
       MergePatchSetInput in,
       ProjectState projectState,
-      Branch.NameKey dest,
+      BranchNameKey dest,
       Repository git,
       ObjectInserter oi,
       RevWalk rw,
@@ -234,7 +234,7 @@
       parentCommit = currentPsCommit.getId();
     } else {
       // get the current branch tip of destination branch
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      Ref destRef = git.getRefDatabase().exactRef(dest.branch());
       if (destRef != null) {
         parentCommit = destRef.getObjectId();
       } else {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index de04d36..b186acf 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -88,13 +88,13 @@
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
       }
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), key.patchSetId);
+      PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), key.patchSetId);
       PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
       Comment c = maybeComment.get();
-      setCommentRevId(c, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
       commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 77894fb..0c8240a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -175,11 +175,11 @@
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
               ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-        if (labelTypes.byLabel(a.getLabelId()) == null) {
+        if (labelTypes.byLabel(a.labelId()) == null) {
           continue; // Ignore undefined labels.
-        } else if (!a.getLabel().equals(label)) {
+        } else if (!a.label().equals(label)) {
           // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.getLabel(), a.getValue());
+          newApprovals.put(a.label(), a.value());
           continue;
         } else {
           try {
@@ -189,11 +189,11 @@
           }
         }
         // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.getLabel(), (short) 0);
+        newApprovals.put(a.label(), (short) 0);
         found = true;
 
         // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.getLabel(), a.getValue());
+        oldApprovals.put(a.label(), a.value());
         break;
       }
       if (!found) {
diff --git a/java/com/google/gerrit/server/restapi/change/DownloadContent.java b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
index 1022cad..60741e7 100644
--- a/java/com/google/gerrit/server/restapi/change/DownloadContent.java
+++ b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.Option;
 
 public class DownloadContent implements RestReadView<FileResource> {
@@ -43,10 +42,9 @@
   @Override
   public BinaryResult apply(FileResource rsrc)
       throws ResourceNotFoundException, IOException, NoSuchChangeException {
-    String path = rsrc.getPatchKey().get();
+    String path = rsrc.getPatchKey().fileName();
     RevisionResource rev = rsrc.getRevision();
-    ObjectId revstr = ObjectId.fromString(rev.getPatchSet().getRevision().get());
     return fileContentUtil.downloadContent(
-        projectCache.checkedGet(rev.getProject()), revstr, path, parent);
+        projectCache.checkedGet(rev.getProject()), rev.getPatchSet().commitId(), path, parent);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index dd61ca0..6a1e0f1 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -66,7 +66,7 @@
     String uuid = id.get();
     for (Comment c :
         commentsUtil.draftByPatchSetAuthor(
-            rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
+            rev.getPatchSet().id(), rev.getAccountId(), rev.getNotes())) {
       if (uuid.equals(c.key.uuid)) {
         return new DraftCommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 2a0cd58..aa3b68c 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -62,7 +62,6 @@
 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;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -163,13 +162,13 @@
             Response.ok(
                 fileInfoJson.toFileInfoMap(
                     resource.getChange(),
-                    resource.getPatchSet().getRevision(),
+                    resource.getPatchSet().commitId(),
                     baseResource.getPatchSet()));
       } else if (parentNum > 0) {
         r =
             Response.ok(
                 fileInfoJson.toFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().getRevision(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
       } else {
         r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -206,8 +205,7 @@
           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(resource.getPatchSet().commitId());
 
         tw.addTree(c.getTree());
         tw.setRecursive(true);
@@ -231,11 +229,11 @@
       Account.Id userId = user.getAccountId();
       PatchSet patchSetId = resource.getPatchSet();
       Optional<PatchSetWithReviewedFiles> o;
-      o = accountPatchReviewStore.call(s -> s.findReviewed(patchSetId.getId(), userId));
+      o = accountPatchReviewStore.call(s -> s.findReviewed(patchSetId.id(), userId));
 
       if (o.isPresent()) {
         PatchSetWithReviewedFiles res = o.get();
-        if (res.patchSetId().equals(patchSetId.getId())) {
+        if (res.patchSetId().equals(patchSetId.id())) {
           return res.files();
         }
 
@@ -314,7 +312,7 @@
         }
 
         accountPatchReviewStore.run(
-            s -> s.markReviewed(resource.getPatchSet().getId(), userId, pathList));
+            s -> s.markReviewed(resource.getPatchSet().id(), userId, pathList));
         return pathList;
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Fixes.java b/java/com/google/gerrit/server/restapi/change/Fixes.java
index 855d1f4..9255ee3 100644
--- a/java/com/google/gerrit/server/restapi/change/Fixes.java
+++ b/java/com/google/gerrit/server/restapi/change/Fixes.java
@@ -54,7 +54,7 @@
     ChangeNotes changeNotes = revisionResource.getNotes();
 
     List<RobotComment> robotComments =
-        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().id());
     for (RobotComment robotComment : robotComments) {
       for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
         if (Objects.equals(fixId, fixSuggestion.fixId)) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetArchive.java b/java/com/google/gerrit/server/restapi/change/GetArchive.java
index 1bd1bce..2bf47e3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetArchive.java
+++ b/java/com/google/gerrit/server/restapi/change/GetArchive.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -27,7 +29,6 @@
 import java.io.OutputStream;
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -65,7 +66,7 @@
       final RevCommit commit;
       String name;
       try (RevWalk rw = new RevWalk(repo)) {
-        commit = rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         name = name(f, rw, commit);
       }
 
@@ -104,6 +105,6 @@
   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());
+        "%s%s", abbreviateName(commit, rw.getObjectReader()), format.getDefaultSuffix());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetBlame.java b/java/com/google/gerrit/server/restapi/change/GetBlame.java
index bb92f00..cade702 100644
--- a/java/com/google/gerrit/server/restapi/change/GetBlame.java
+++ b/java/com/google/gerrit/server/restapi/change/GetBlame.java
@@ -87,7 +87,7 @@
       String refName =
           resource.getRevision().getEdit().isPresent()
               ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
+              : resource.getRevision().getPatchSet().refName();
 
       Ref ref = repository.findRef(refName);
       if (ref == null) {
@@ -97,7 +97,7 @@
       RevCommit revCommit = revWalk.parseCommit(objectId);
       RevCommit[] parents = revCommit.getParents();
 
-      String path = resource.getPatchKey().getFileName();
+      String path = resource.getPatchKey().fileName();
 
       List<BlameInfo> result;
       if (!base) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 29286cb..aeaafc4 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -26,7 +26,6 @@
 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;
@@ -55,8 +54,7 @@
     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));
+      RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
       rw.parseBody(commit);
       CommitInfo info =
           json.create(ImmutableSet.of())
diff --git a/java/com/google/gerrit/server/restapi/change/GetContent.java b/java/com/google/gerrit/server/restapi/change/GetContent.java
index 1d35ab5..62889a3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetContent.java
+++ b/java/com/google/gerrit/server/restapi/change/GetContent.java
@@ -33,7 +33,6 @@
 import com.google.inject.Inject;
 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;
@@ -63,7 +62,7 @@
   @Override
   public BinaryResult apply(FileResource rsrc)
       throws ResourceNotFoundException, IOException, BadRequestException {
-    String path = rsrc.getPatchKey().get();
+    String path = rsrc.getPatchKey().fileName();
     if (Patch.COMMIT_MSG.equals(path)) {
       String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
       return BinaryResult.create(msg)
@@ -77,7 +76,7 @@
     }
     return fileContentUtil.getContent(
         projectCache.checkedGet(rsrc.getRevision().getProject()),
-        ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
+        rsrc.getRevision().getPatchSet().commitId(),
         path,
         parent);
   }
@@ -91,7 +90,7 @@
 
     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(ps.commitId());
       return commit.getFullMessage();
     } catch (RepositoryNotFoundException e) {
       throw new NoSuchChangeException(changeId, e);
@@ -108,9 +107,7 @@
     try (Repository git = gitManager.openRepository(notes.getProjectName());
         RevWalk revWalk = new RevWalk(git)) {
       return Text.forMergeList(
-              ComparisonType.againstAutoMerge(),
-              revWalk.getObjectReader(),
-              ObjectId.fromString(ps.getRevision().get()))
+              ComparisonType.againstAutoMerge(), revWalk.getObjectReader(), ps.commitId())
           .getContent();
     } catch (RepositoryNotFoundException e) {
       throw new NoSuchChangeException(changeId, e);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDescription.java b/java/com/google/gerrit/server/restapi/change/GetDescription.java
index 1a7ec63..c30bd0d 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDescription.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.inject.Singleton;
@@ -23,6 +22,6 @@
 public class GetDescription implements RestReadView<RevisionResource> {
   @Override
   public String apply(RevisionResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getPatchSet().getDescription());
+    return rsrc.getPatchSet().description().orElse("");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 761500a..e47865c 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -140,14 +140,14 @@
 
     PatchScriptFactory psf;
     PatchSet basePatchSet = null;
-    PatchSet.Id pId = resource.getPatchKey().getParentKey();
-    String fileName = resource.getPatchKey().getFileName();
+    PatchSet.Id pId = resource.getPatchKey().patchSetId();
+    String fileName = resource.getPatchKey().fileName();
     ChangeNotes notes = resource.getRevision().getNotes();
     if (base != null) {
       RevisionResource baseResource =
           revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
       basePatchSet = baseResource.getPatchSet();
-      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.getId(), pId, prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.id(), pId, prefs);
     } else if (parentNum > 0) {
       psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
     } else {
@@ -195,20 +195,20 @@
       ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
 
       DiffInfo result = new DiffInfo();
-      String revA = basePatchSet != null ? basePatchSet.getRefName() : content.commitIdA;
+      String revA = basePatchSet != null ? basePatchSet.refName() : content.commitIdA;
       String revB =
           resource.getRevision().getEdit().isPresent()
               ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
+              : resource.getRevision().getPatchSet().refName();
 
-      List<DiffWebLinkInfo> links =
+      ImmutableList<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
               state.getName(),
-              resource.getPatchKey().getParentKey().getParentKey().get(),
-              basePatchSet != null ? basePatchSet.getId().get() : null,
+              resource.getPatchKey().patchSetId().changeId().get(),
+              basePatchSet != null ? basePatchSet.id().get() : null,
               revA,
               MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-              resource.getPatchKey().getParentKey().get(),
+              resource.getPatchKey().patchSetId().get(),
               revB,
               ps.getNewName());
       result.webLinks = links.isEmpty() ? null : links;
@@ -273,7 +273,7 @@
   }
 
   private List<WebLinkInfo> getFileWebLinks(Project project, String rev, String file) {
-    List<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
+    ImmutableList<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
     return links.isEmpty() ? null : links;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 0c18a8f..48d6dcb 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -31,7 +31,6 @@
 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;
@@ -67,8 +66,7 @@
     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));
+      RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
       rw.parseBody(commit);
 
       if (uninterestingParent < 1 || uninterestingParent > commit.getParentCount()) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index ccad9e0..186752e 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -31,8 +32,6 @@
 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;
@@ -67,8 +66,7 @@
     try {
       final RevWalk rw = new RevWalk(repo);
       try {
-        final RevCommit commit =
-            rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         RevCommit[] parents = commit.getParents();
         if (parents.length > 1) {
           throw new ResourceConflictException("Revision has more than 1 parent.");
@@ -189,7 +187,6 @@
   }
 
   private static String fileName(RevWalk rw, RevCommit commit) throws IOException {
-    AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 7);
-    return id.name() + ".diff";
+    return abbreviateName(commit, rw.getObjectReader()) + ".diff";
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 332cc4d..fab081b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -102,7 +102,7 @@
     for (RelatedChangesSorter.PatchSetData d : sorter.sort(cds, basePs)) {
       PatchSet ps = d.patchSet();
       RevCommit commit;
-      if (isEdit && ps.getId().equals(basePs.getId())) {
+      if (isEdit && ps.id().equals(basePs.id())) {
         // Replace base of an edit with the edit itself.
         ps = rsrc.getPatchSet();
         commit = rsrc.getEdit().get().getEditCommit();
@@ -114,7 +114,7 @@
 
     if (result.size() == 1) {
       RelatedChangeAndCommitInfo 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().commitId().name())) {
         return Collections.emptyList();
       }
     }
@@ -123,13 +123,13 @@
 
   @VisibleForTesting
   public static Set<String> getAllGroups(ChangeNotes notes, PatchSetUtil psUtil) {
-    return psUtil.byChange(notes).stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet());
+    return psUtil.byChange(notes).stream().flatMap(ps -> ps.groups().stream()).collect(toSet());
   }
 
   private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) {
     for (ChangeData cd : cds) {
-      if (cd.getId().equals(wantedPs.getId().getParentKey())) {
-        if (cd.patchSet(wantedPs.getId()) == null) {
+      if (cd.getId().equals(wantedPs.id().changeId())) {
+        if (cd.patchSet(wantedPs.id()) == null) {
           cd.reloadChange();
         }
       }
@@ -144,7 +144,7 @@
     if (change != null) {
       info.changeId = change.getKey().get();
       info._changeNumber = change.getChangeId();
-      info._revisionNumber = ps != null ? ps.getPatchSetId() : null;
+      info._revisionNumber = ps != null ? ps.number() : null;
       PatchSet.Id curr = change.currentPatchSetId();
       info._currentRevisionNumber = curr != null ? curr.get() : null;
       info.status = ChangeUtil.status(change).toUpperCase(Locale.US);
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
index b39ba63..f2e5a37 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -37,6 +37,6 @@
   @Override
   protected Iterable<Comment> listComments(RevisionResource rsrc) {
     ChangeNotes notes = rsrc.getNotes();
-    return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().getId());
+    return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().id());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index a46bd6c..73b92f5 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -40,7 +40,7 @@
 
   protected Iterable<Comment> listComments(RevisionResource rsrc) {
     return commentsUtil.draftByPatchSetAuthor(
-        rsrc.getPatchSet().getId(), rsrc.getAccountId(), rsrc.getNotes());
+        rsrc.getPatchSet().id(), rsrc.getAccountId(), rsrc.getNotes());
   }
 
   protected boolean includeAuthorInfo() {
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index bbf46a3..d617a10 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -58,6 +58,6 @@
   }
 
   private Iterable<RobotComment> listComments(RevisionResource rsrc) {
-    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().getId());
+    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().id());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index ece8938..f20e03d 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -50,8 +49,6 @@
 import org.kohsuke.args4j.Option;
 
 public class Mergeable implements RestReadView<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   @Option(
       name = "--other-branches",
       aliases = {"-o"},
@@ -97,7 +94,7 @@
 
     if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    } else if (!ps.getId().equals(change.currentPatchSetId())) {
+    } else if (!ps.id().equals(change.currentPatchSetId())) {
       // Only the current revision is mergeable. Others always fail.
       return result;
     }
@@ -106,8 +103,8 @@
     result.submitType = getSubmitType(cd);
 
     try (Repository git = gitManager.openRepository(change.getProject())) {
-      ObjectId commit = toId(ps);
-      Ref ref = git.getRefDatabase().exactRef(change.getDest().get());
+      ObjectId commit = ps.commitId();
+      Ref ref = git.getRefDatabase().exactRef(change.getDest().branch());
       ProjectState projectState = projectCache.get(change.getProject());
       String strategy = mergeUtilFactory.create(projectState).mergeStrategyName();
       result.strategy = strategy;
@@ -161,15 +158,6 @@
     return refresh(change, commit, ref, submitType, strategy, git, old);
   }
 
-  private static ObjectId toId(PatchSet ps) {
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      logger.atSevere().log("Invalid revision on patch set %s", ps);
-      return null;
-    }
-  }
-
   private boolean refresh(
       final Change change,
       ObjectId commit,
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index f2335b1..b5c774c 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.LabelId;
@@ -135,7 +135,7 @@
       throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
     }
 
-    Branch.NameKey newDest = new Branch.NameKey(project, input.destinationBranch);
+    BranchNameKey newDest = BranchNameKey.create(project, input.destinationBranch);
     if (change.getDest().equals(newDest)) {
       throw new ResourceConflictException("Change is already destined for the specified branch");
     }
@@ -164,7 +164,7 @@
     private final MoveInput input;
 
     private Change change;
-    private Branch.NameKey newDestKey;
+    private BranchNameKey newDestKey;
 
     Op(MoveInput input) {
       this.input = input;
@@ -183,8 +183,8 @@
       }
 
       Project.NameKey projectKey = change.getProject();
-      newDestKey = new Branch.NameKey(projectKey, input.destinationBranch);
-      Branch.NameKey changePrevDest = change.getDest();
+      newDestKey = BranchNameKey.create(projectKey, input.destinationBranch);
+      BranchNameKey changePrevDest = change.getDest();
       if (changePrevDest.equals(newDestKey)) {
         throw new ResourceConflictException("Change is already destined for the specified branch");
       }
@@ -193,8 +193,7 @@
       try (Repository repo = repoManager.openRepository(projectKey);
           RevWalk revWalk = new RevWalk(repo)) {
         RevCommit currPatchsetRevCommit =
-            revWalk.parseCommit(
-                ObjectId.fromString(psUtil.current(ctx.getNotes()).getRevision().get()));
+            revWalk.parseCommit(psUtil.current(ctx.getNotes()).commitId());
         if (currPatchsetRevCommit.getParentCount() > 1) {
           throw new ResourceConflictException("Merge commit cannot be moved");
         }
@@ -216,7 +215,7 @@
       if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
         throw new ResourceConflictException(
             "Destination "
-                + newDestKey.getShortName()
+                + newDestKey.shortName()
                 + " has a different change with same change key "
                 + changeKey);
       }
@@ -227,16 +226,16 @@
 
       PatchSet.Id psId = change.currentPatchSetId();
       ChangeUpdate update = ctx.getUpdate(psId);
-      update.setBranch(newDestKey.get());
+      update.setBranch(newDestKey.branch());
       change.setDest(newDestKey);
 
       updateApprovals(ctx, update, psId, projectKey);
 
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
-      msgBuf.append(changePrevDest.getShortName());
+      msgBuf.append(changePrevDest.shortName());
       msgBuf.append(" to ");
-      msgBuf.append(newDestKey.getShortName());
+      msgBuf.append(newDestKey.shortName());
       if (!Strings.isNullOrEmpty(input.message)) {
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
@@ -262,7 +261,7 @@
           approvalsUtil.byPatchSet(
               ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
         ProjectState projectState = projectCache.checkedGet(project);
-        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.getLabelId());
+        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
         // 2- the vote holds the minimum value.
@@ -271,12 +270,13 @@
         }
 
         // Remove votes from NoteDb.
-        update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+        update.removeApprovalFor(psa.accountId(), psa.label());
         approvals.add(
-            new PatchSetApproval(
-                new PatchSetApproval.Key(psId, psa.getAccountId(), new LabelId(psa.getLabel())),
-                (short) 0,
-                ctx.getWhen()));
+            PatchSetApproval.builder()
+                .key(PatchSetApproval.key(psId, psa.accountId(), LabelId.create(psa.label())))
+                .value(0)
+                .granted(ctx.getWhen())
+                .build());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 9a843cd..18d668a 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -133,6 +133,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.OptionalInt;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -356,8 +357,7 @@
 
       // Add the review op.
       bu.addOp(
-          revision.getChange().getId(),
-          new Op(projectState, revision.getPatchSet().getId(), input));
+          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
 
       // Notify based on ReviewInput, ignoring the notify settings from any AddReviewerInputs.
       NotifyResolver.Result notify =
@@ -570,7 +570,7 @@
     Set<String> revisionFilePaths = getAffectedFilePaths(revision);
     for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
       String path = entry.getKey();
-      PatchSet.Id patchSetId = revision.getPatchSet().getId();
+      PatchSet.Id patchSetId = revision.getPatchSet().id();
       ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
 
       List<T> comments = entry.getValue();
@@ -584,7 +584,7 @@
 
   private Set<String> getAffectedFilePaths(RevisionResource revision)
       throws PatchListNotAvailableException {
-    ObjectId newId = ObjectId.fromString(revision.getPatchSet().getRevision().get());
+    ObjectId newId = revision.getPatchSet().commitId();
     DiffSummaryKey key =
         DiffSummaryKey.fromPatchListKey(
             PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
@@ -921,7 +921,7 @@
             e.message = c.message;
           }
 
-          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
+          setCommentCommitId(e, patchListCache, ctx.getChange(), ps);
           e.setLineNbrAndRange(c.line, c.range);
           e.tag = in.tag;
 
@@ -996,7 +996,7 @@
       robotComment.properties = robotCommentInput.properties;
       robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
       robotComment.tag = in.tag;
-      setCommentRevId(robotComment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
       robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
       return robotComment;
     }
@@ -1062,7 +1062,7 @@
     private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
       Map<String, Short> labels = new HashMap<>();
       for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.getLabel(), psa.getValue());
+        labels.put(psa.label(), psa.value());
       }
       return labels;
     }
@@ -1142,35 +1142,40 @@
           // User requested delete of this label.
           oldApprovals.put(normName, null);
           if (c != null) {
-            if (c.getValue() != 0) {
+            if (c.value() != 0) {
               addLabelDelta(normName, (short) 0);
               oldApprovals.put(normName, previous.get(normName));
             }
             del.add(c);
             update.putApproval(normName, (short) 0);
           }
-        } else if (c != null && c.getValue() != ent.getValue()) {
-          c.setValue(ent.getValue());
-          c.setGranted(ctx.getWhen());
-          c.setTag(in.tag);
-          ctx.getUser().updateRealAccountId(c::setRealAccountId);
+        } else if (c != null && c.value() != ent.getValue()) {
+          PatchSetApproval.Builder b =
+              c.toBuilder()
+                  .value(ent.getValue())
+                  .granted(ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag));
+          ctx.getUser().updateRealAccountId(b::realAccountId);
+          c = b.build();
           ups.add(c);
-          addLabelDelta(normName, c.getValue());
+          addLabelDelta(normName, c.value());
           oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
           update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.getValue() == ent.getValue()) {
+        } else if (c != null && c.value() == ent.getValue()) {
           current.put(normName, c);
           oldApprovals.put(normName, null);
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
         } else if (c == null) {
-          c = ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
+          c =
+              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag))
+                  .granted(ctx.getWhen())
+                  .build();
           ups.add(c);
-          addLabelDelta(normName, c.getValue());
+          addLabelDelta(normName, c.value());
           oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
+          approvals.put(normName, c.value());
           update.putReviewer(user.getAccountId(), REVIEWER);
           update.putApproval(normName, ent.getValue());
         }
@@ -1212,7 +1217,7 @@
       List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
 
       for (PatchSetApproval psa : del) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.getLabel()));
+        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
         if (!lt.allowPostSubmit()) {
           disallowed.add(normName);
@@ -1224,7 +1229,7 @@
       }
 
       for (PatchSetApproval psa : ups) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.getLabel()));
+        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
         if (!lt.allowPostSubmit()) {
           disallowed.add(normName);
@@ -1233,8 +1238,8 @@
         if (prev == null) {
           continue;
         }
-        checkState(prev != psa.getValue()); // Should be filtered out above.
-        if (prev > psa.getValue()) {
+        checkState(prev != psa.value()); // Should be filtered out above.
+        if (prev > psa.value()) {
           reduced.add(psa);
         }
         // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
@@ -1249,7 +1254,7 @@
         throw new ResourceConflictException(
             "Cannot reduce vote on labels for closed change: "
                 + reduced.stream()
-                    .map(PatchSetApproval::getLabel)
+                    .map(PatchSetApproval::label)
                     .distinct()
                     .sorted()
                     .collect(joining(", ")));
@@ -1276,18 +1281,16 @@
           }
 
           LabelId labelId = labelTypes.get(0).getLabelId();
-          PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
-          ups.add(c);
+          ups.add(
+              ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen())
+                  .tag(Optional.ofNullable(in.tag))
+                  .granted(ctx.getWhen())
+                  .build());
         } else {
           // Pick a random label that is about to be deleted and keep it.
           Iterator<PatchSetApproval> i = del.iterator();
-          PatchSetApproval c = i.next();
-          c.setValue((short) 0);
-          c.setGranted(ctx.getWhen());
+          ups.add(i.next().toBuilder().value(0).granted(ctx.getWhen()).build());
           i.remove();
-          ups.add(c);
         }
       }
       ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
@@ -1310,7 +1313,7 @@
           continue;
         }
 
-        LabelType lt = labelTypes.byLabel(a.getLabelId());
+        LabelType lt = labelTypes.byLabel(a.labelId());
         if (lt != null) {
           current.put(lt.getName(), a);
         } else {
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 0ec38e1..a2bff76 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -57,7 +57,7 @@
       throws UpdateException, RestApiException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
 
-    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().getId());
+    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
     try (BatchUpdate u =
         updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getChange().getId(), op);
@@ -84,7 +84,7 @@
     public boolean updateChange(ChangeContext ctx) {
       ChangeUpdate update = ctx.getUpdate(psId);
       newDescription = Strings.nullToEmpty(input.description);
-      oldDescription = Strings.nullToEmpty(psUtil.get(ctx.getNotes(), psId).getDescription());
+      oldDescription = psUtil.get(ctx.getNotes(), psId).description().orElse("");
       if (oldDescription.equals(newDescription)) {
         return false;
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index f6c9abe..241e7e1 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -124,7 +124,7 @@
       // user.
       ctx.getUser().updateRealAccountId(comment::setRealAuthor);
 
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
+      PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), origComment.key.patchSetId);
       ChangeUpdate update = ctx.getUpdate(psId);
 
       PatchSet ps = psUtil.get(ctx.getNotes(), psId);
@@ -138,7 +138,7 @@
         commentsUtil.deleteComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
       commentsUtil.putComments(
           update, Status.DRAFT, Collections.singleton(update(comment, in, ctx.getWhen())));
       return true;
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index c542164..db02418 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -119,7 +119,7 @@
     try (Repository repository = repositoryManager.openRepository(resource.getProject());
         RevWalk revWalk = new RevWalk(repository);
         ObjectInserter objectInserter = repository.newObjectInserter()) {
-      RevCommit patchSetCommit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      RevCommit patchSetCommit = revWalk.parseCommit(ps.commitId());
 
       String currentCommitMessage = patchSetCommit.getFullMessage();
       if (input.message.equals(currentCommitMessage)) {
@@ -132,7 +132,7 @@
         // Ensure that BatchUpdate will update the same repo
         bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
 
-        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.getId());
+        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
         ObjectId newCommit =
             createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
         PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 4e6f65e..dbe9eff 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -26,7 +26,7 @@
 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.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeUtil;
@@ -137,7 +137,7 @@
       Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
       throws RestApiException, IOException, NoSuchChangeException, AuthException,
           PermissionBackendException {
-    Branch.NameKey destRefKey = rsrc.getChange().getDest();
+    BranchNameKey destRefKey = rsrc.getChange().getDest();
     if (input == null || input.base == null) {
       return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
     }
@@ -146,10 +146,10 @@
     String str = input.base.trim();
     if (str.equals("")) {
       // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.get());
+      Ref destRef = repo.exactRef(destRefKey.branch());
       if (destRef == null) {
         throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
+            "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
       }
       return destRef.getObjectId();
     }
@@ -159,8 +159,8 @@
       throw new ResourceConflictException(
           "base revision is missing from the destination branch: " + str);
     }
-    PatchSet.Id baseId = base.patchSet().getId();
-    if (change.getId().equals(baseId.getParentKey())) {
+    PatchSet.Id baseId = base.patchSet().id();
+    if (change.getId().equals(baseId.changeId())) {
       throw new ResourceConflictException("cannot rebase change onto itself");
     }
 
@@ -181,18 +181,18 @@
               + baseChange.getKey()
               + " is a descendant of the current change - recursion not allowed");
     }
-    return ObjectId.fromString(base.patchSet().getRevision().get());
+    return base.patchSet().commitId();
   }
 
   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());
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
     return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
     // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+    RevCommit c = rw.parseCommit(ps.commitId());
     return c.getParentCount() == 1;
   }
 
@@ -230,7 +230,7 @@
     }
 
     boolean enabled = false;
-    try (Repository repo = repoManager.openRepository(change.getDest().getParentKey());
+    try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
       if (hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
index 937ab56..987da56 100644
--- a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
@@ -74,8 +74,8 @@
       throws IOException, PermissionBackendException {
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
-    Map<String, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.getRevision().get());
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = byId.get(startPs.commitId());
     checkArgument(start != null, "%s not found in %s", startPs, in);
 
     // Map of patch set -> immediate parent.
@@ -89,12 +89,12 @@
 
     for (ChangeData cd : in) {
       for (PatchSet ps : cd.patchSets()) {
-        PatchSetData thisPsd = requireNonNull(byId.get(ps.getRevision().get()));
-        if (cd.getId().equals(start.id()) && !ps.getId().equals(start.psId())) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+        if (cd.getId().equals(start.id()) && !ps.id().equals(start.psId())) {
           otherPatchSetsOfStart.add(thisPsd);
         }
         for (RevCommit p : thisPsd.commit().getParents()) {
-          PatchSetData parentPsd = byId.get(p.name());
+          PatchSetData parentPsd = byId.get(p);
           if (parentPsd != null) {
             parents.put(thisPsd, parentPsd);
             children.put(parentPsd, thisPsd);
@@ -112,9 +112,9 @@
     return result;
   }
 
-  private Map<String, PatchSetData> collectById(List<ChangeData> in) throws IOException {
+  private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
-    Map<String, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
+    Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(true);
@@ -126,10 +126,9 @@
             project,
             cd.change().getProject());
         for (PatchSet ps : cd.patchSets()) {
-          String id = ps.getRevision().get();
-          RevCommit c = rw.parseCommit(ObjectId.fromString(id));
+          RevCommit c = rw.parseCommit(ps.commitId());
           PatchSetData psd = PatchSetData.create(cd, ps, c);
-          result.put(id, psd);
+          result.put(ps.commitId(), psd);
         }
       }
     }
@@ -252,16 +251,16 @@
     abstract RevCommit commit();
 
     PatchSet.Id psId() {
-      return patchSet().getId();
+      return patchSet().id();
     }
 
     Change.Id id() {
-      return psId().getParentKey();
+      return psId().changeId();
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(patchSet().getId(), commit());
+      return Objects.hash(patchSet().id(), commit());
     }
 
     @Override
@@ -270,7 +269,7 @@
         return false;
       }
       PatchSetData o = (PatchSetData) obj;
-      return Objects.equals(patchSet().getId(), o.patchSet().getId())
+      return Objects.equals(patchSet().id(), o.patchSet().id())
           && Objects.equals(commit(), o.commit());
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 3fe6770..dc6a073a 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -172,8 +172,7 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
-      RevCommit commitToRevert =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      RevCommit commitToRevert = revWalk.parseCommit(patch.commitId());
       if (commitToRevert.getParentCount() == 0) {
         throw new ResourceConflictException("Cannot revert initial commit");
       }
@@ -198,7 +197,7 @@
             MessageFormat.format(
                 ChangeMessages.get().revertChangeDefaultMessage,
                 changeToRevert.getSubject(),
-                patch.getRevision().get());
+                patch.commitId().name());
       }
 
       ObjectId computedChangeId =
@@ -210,7 +209,7 @@
               message);
       revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, computedChangeId, true));
 
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      Change.Id changeId = Change.id(seq.nextChangeId());
       ObjectId id = oi.insert(revertCommitBuilder);
       RevCommit revertCommit = revWalk.parseCommit(id);
 
@@ -220,7 +219,7 @@
 
       ChangeInserter ins =
           changeInserterFactory
-              .create(changeId, revertCommit, notes.getChange().getDest().get())
+              .create(changeId, revertCommit, notes.getChange().getDest().branch())
               .setTopic(changeToRevert.getTopic());
       ins.setMessage("Uploaded patch set 1.");
 
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewed.java b/java/com/google/gerrit/server/restapi/change/Reviewed.java
index 4594503..7152799 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewed.java
@@ -40,9 +40,9 @@
           accountPatchReviewStore.call(
               s ->
                   s.markReviewed(
-                      resource.getPatchKey().getParentKey(),
+                      resource.getPatchKey().patchSetId(),
                       resource.getAccountId(),
-                      resource.getPatchKey().getFileName()));
+                      resource.getPatchKey().fileName()));
       return reviewFlagUpdated ? Response.created("") : Response.ok("");
     }
   }
@@ -61,9 +61,9 @@
       accountPatchReviewStore.run(
           s ->
               s.clearReviewed(
-                  resource.getPatchKey().getParentKey(),
+                  resource.getPatchKey().patchSetId(),
                   resource.getAccountId(),
-                  resource.getPatchKey().getFileName()));
+                  resource.getPatchKey().fileName()));
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index ed487f7..ea7182f 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -209,7 +209,7 @@
       Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
       for (ChangeData cd : result) {
         for (PatchSetApproval approval : cd.currentApprovals()) {
-          Account.Id id = approval.getAccountId();
+          Account.Id id = approval.accountId();
           if (suggestions.containsKey(id)) {
             suggestions.get(id).add(baseWeight);
           } else {
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 779f277..1704153 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -247,7 +247,7 @@
                 .readRaw();
         List<Account.Id> matches =
             result.toList().stream()
-                .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
+                .map(f -> Account.id(f.getValue(AccountField.ID).intValue()))
                 .collect(toList());
         logger.atFine().log("Matches: %s", matches);
         return matches;
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index c5cce4f..dfba895 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -16,14 +16,15 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 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.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -36,11 +37,13 @@
 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.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
@@ -121,42 +124,46 @@
     } else if (id.length() < 6 && id.matches("^[1-9][0-9]{0,4}$")) {
       // Legacy patch set number syntax.
       return byLegacyPatchSetId(change, id);
-    } else if (id.length() < 4 || id.length() > RevId.LEN) {
+    } else if (id.length() < 4 || id.length() > ObjectIds.STR_LEN) {
       // Require a minimum of 4 digits.
       // Impossibly long identifier will never match.
       return Collections.emptyList();
     } else {
       List<RevisionResource> out = new ArrayList<>();
       for (PatchSet ps : psUtil.byChange(change.getNotes())) {
-        if (ps.getRevision() != null && ps.getRevision().get().startsWith(id)) {
+        if (ObjectIds.matchesAbbreviation(ps.commitId(), id)) {
           out.add(new RevisionResource(change, ps));
         }
       }
       // Not an existing patch set on a change, but might be an edit.
-      if (out.isEmpty() && id.length() == RevId.LEN) {
-        return loadEdit(change, new RevId(id));
+      if (out.isEmpty() && ObjectId.isId(id)) {
+        return loadEdit(change, ObjectId.fromString(id));
       }
       return out;
     }
   }
 
   private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
-    PatchSet ps =
-        psUtil.get(change.getNotes(), new PatchSet.Id(change.getId(), Integer.parseInt(id)));
+    PatchSet ps = psUtil.get(change.getNotes(), PatchSet.id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
       return Collections.singletonList(new RevisionResource(change, ps));
     }
     return Collections.emptyList();
   }
 
-  private List<RevisionResource> loadEdit(ChangeResource change, RevId revid)
+  private List<RevisionResource> loadEdit(ChangeResource change, @Nullable ObjectId commitId)
       throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
     if (edit.isPresent()) {
-      PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
-      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
-      ps.setRevision(editRevId);
-      if (revid == null || editRevId.equals(revid)) {
+      RevCommit editCommit = edit.get().getEditCommit();
+      PatchSet ps =
+          PatchSet.builder()
+              .id(PatchSet.id(change.getId(), 0))
+              .commitId(editCommit)
+              .uploader(change.getUser().getAccountId())
+              .createdOn(new Timestamp(editCommit.getCommitterIdent().getWhen().getTime()))
+              .build();
+      if (commitId == null || editCommit.equals(commitId)) {
         return Collections.singletonList(new RevisionResource(change, ps, edit));
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RobotComments.java b/java/com/google/gerrit/server/restapi/change/RobotComments.java
index 1aa8c2a..4ff8ca9 100644
--- a/java/com/google/gerrit/server/restapi/change/RobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/RobotComments.java
@@ -59,7 +59,7 @@
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().getId())) {
+    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
         return new RobotCommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index d12d7c6..8aadadd 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.MoreObjects;
@@ -32,11 +33,10 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 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.RevId;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -197,12 +197,12 @@
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
       throw new ResourceConflictException(
-          String.format("destination branch \"%s\" not found.", change.getDest().get()));
-    } else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) {
+          String.format("destination branch \"%s\" not found.", change.getDest().branch()));
+    } else if (!rsrc.getPatchSet().id().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()));
+              "revision %s is not current revision", rsrc.getPatchSet().commitId().name()));
     }
 
     try (MergeOp op = mergeOpProvider.get()) {
@@ -364,12 +364,11 @@
           .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", change.getDest().getShortName(),
-            "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
+            "patchSet", String.valueOf(resource.getPatchSet().number()),
+            "branch", change.getDest().shortName(),
+            "commit", abbreviateName(resource.getPatchSet().commitId()),
             "submitSize", String.valueOf(cs.size()));
     ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
     return new UiAction.Description()
@@ -385,10 +384,10 @@
       mergeabilityMap.add(change);
     }
 
-    ListMultimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
-    for (Branch.NameKey branch : cbb.keySet()) {
+    ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
+    for (BranchNameKey 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.project());
 
       Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
       for (RevCommit commit : commits.values()) {
@@ -433,9 +432,7 @@
     try (Repository repo = repoManager.openRepository(project);
         RevWalk walk = new RevWalk(repo)) {
       for (ChangeData change : changes) {
-        RevCommit commit =
-            walk.parseCommit(
-                ObjectId.fromString(psUtil.current(change.notes()).getRevision().get()));
+        RevCommit commit = walk.parseCommit(psUtil.current(change.notes()).commitId());
         commits.put(change.getId(), commit);
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index efefc3e..abbd580 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -57,7 +57,8 @@
       EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.SUBMITTABLE);
 
   private static final Comparator<ChangeData> COMPARATOR =
-      Comparator.comparing(ChangeData::project).thenComparing(cd -> cd.getId().id, reverseOrder());
+      Comparator.comparing(ChangeData::project)
+          .thenComparing(cd -> cd.getId().get(), reverseOrder());
 
   private final ChangeJson.Factory json;
   private final Provider<InternalChangeQuery> queryProvider;
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
index 31efe54..8f48aa5 100644
--- a/java/com/google/gerrit/server/restapi/change/Votes.java
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -85,7 +85,7 @@
               null,
               null);
       for (PatchSetApproval psa : byPatchSetUser) {
-        votes.put(psa.getLabel(), psa.getValue());
+        votes.put(psa.label(), psa.value());
       }
       return votes;
     }
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index 7402c15..6f18b24 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -83,7 +83,7 @@
       if (task.projectName != null) {
         Boolean visible = visibilityCache.get(task.projectName);
         if (visible == null) {
-          Project.NameKey nameKey = new Project.NameKey(task.projectName);
+          Project.NameKey nameKey = Project.nameKey(task.projectName);
           ProjectState state = projectCache.get(nameKey);
           if (state == null || !state.statePermitsRead()) {
             visible = false;
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index ffa462d..043bbd9 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -193,7 +193,7 @@
       }
     }
 
-    AccountGroup.Id groupId = new AccountGroup.Id(sequences.nextGroupId());
+    AccountGroup.Id groupId = AccountGroup.id(sequences.nextGroupId());
     AccountGroup.UUID uuid =
         GroupUUID.make(
             createGroupArgs.getGroupName(),
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index 1a781d9..195ac4a 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupBackend;
@@ -90,22 +90,24 @@
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       for (AccountGroupMemberAudit auditEvent :
           groups.getMembersAudit(allUsersRepo, group.getGroupUUID())) {
-        AccountInfo member = accountLoader.get(auditEvent.getMemberId());
+        AccountInfo member = accountLoader.get(auditEvent.memberId());
 
         auditEvents.add(
             GroupAuditEventInfo.createAddUserEvent(
-                accountLoader.get(auditEvent.getAddedBy()), auditEvent.getAddedOn(), member));
+                accountLoader.get(auditEvent.addedBy()), auditEvent.addedOn(), member));
 
         if (!auditEvent.isActive()) {
           auditEvents.add(
               GroupAuditEventInfo.createRemoveUserEvent(
-                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+                  accountLoader.get(auditEvent.removedBy().orElse(null)),
+                  auditEvent.removedOn(),
+                  member));
         }
       }
 
-      for (AccountGroupByIdAud auditEvent :
+      for (AccountGroupByIdAudit auditEvent :
           groups.getSubgroupsAudit(allUsersRepo, group.getGroupUUID())) {
-        AccountGroup.UUID includedGroupUUID = auditEvent.getIncludeUUID();
+        AccountGroup.UUID includedGroupUUID = auditEvent.includeUuid();
         Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
         GroupInfo member;
         if (includedGroup.isPresent()) {
@@ -121,14 +123,14 @@
 
         auditEvents.add(
             GroupAuditEventInfo.createAddGroupEvent(
-                accountLoader.get(auditEvent.getAddedBy()),
-                auditEvent.getKey().getAddedOn(),
-                member));
+                accountLoader.get(auditEvent.addedBy()), auditEvent.addedOn(), member));
 
         if (!auditEvent.isActive()) {
           auditEvents.add(
               GroupAuditEventInfo.createRemoveGroupEvent(
-                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+                  accountLoader.get(auditEvent.removedBy().orElse(null)),
+                  auditEvent.removedOn(),
+                  member));
         }
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
index 319dc45..0cf5fa9 100644
--- a/java/com/google/gerrit/server/restapi/group/PutName.java
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -74,7 +74,7 @@
           ConfigInvalidException {
     AccountGroup.UUID groupUuid = group.getGroupUUID();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(newName)).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey(newName)).build();
     try {
       groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
     } catch (NoSuchGroupException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 67b68b5..41bcbb8 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -24,7 +24,7 @@
 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.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
@@ -106,7 +106,7 @@
       try {
         permissionBackend
             .absentUser(match)
-            .ref(new Branch.NameKey(rsrc.getNameKey(), input.ref))
+            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
             .check(refPerm);
       } catch (AuthException e) {
         info.status = HttpServletResponse.SC_FORBIDDEN;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index ee3d0a8..2734da2 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -112,7 +112,7 @@
     List<AccessSection> additions = setAccess.getAccessSections(input.add);
 
     Project.NameKey newParentProjectName =
-        input.parent == null ? null : new Project.NameKey(input.parent);
+        input.parent == null ? null : Project.nameKey(input.parent);
 
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
@@ -134,11 +134,10 @@
 
       md.setMessage("Review access change");
       md.setInsertChangeId(true);
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      Change.Id changeId = Change.id(seq.nextChangeId());
 
       RevCommit commit =
-          config.commitToNewRef(
-              md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+          config.commitToNewRef(md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
 
       if (commit.name().equals(oldCommitSha1)) {
         throw new BadRequestException("no change");
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 62106e8..e86230c 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -108,7 +108,7 @@
               + "\"");
     }
 
-    final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
+    final BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -140,7 +140,7 @@
           case NEW:
           case NO_CHANGE:
             referenceUpdated.fire(
-                name.getParentKey(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+                name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
             break;
           case LOCK_FAILURE:
             if (repo.getRefDatabase().exactRef(ref) != null) {
@@ -178,7 +178,7 @@
         info.ref = ref;
         info.revision = revid.getName();
 
-        if (isConfigRef(name.get())) {
+        if (isConfigRef(name.branch())) {
           // Never allow to delete the meta config branch.
           info.canDelete = null;
         } else {
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
index 3a2bd6f..92949fa 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -46,10 +46,10 @@
   @Override
   public Response<?> apply(BranchResource rsrc, Input input)
       throws RestApiException, IOException, PermissionBackendException {
-    if (isConfigRef(rsrc.getBranchKey().get())) {
+    if (isConfigRef(rsrc.getBranchKey().branch())) {
       // Never allow to delete the meta config branch.
       throw new MethodNotAllowedException(
-          "not allowed to delete branch " + rsrc.getBranchKey().get());
+          "not allowed to delete branch " + rsrc.getBranchKey().branch());
     }
 
     if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 8861c0b..6b7987c 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.common.Nullable;
 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.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -260,7 +260,7 @@
     }
 
     if (!refName.startsWith(R_TAGS)) {
-      Branch.NameKey branchKey = new Branch.NameKey(projectState.getNameKey(), ref.getName());
+      BranchNameKey branchKey = BranchNameKey.create(projectState.getNameKey(), ref.getName());
       if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
         command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
       }
diff --git a/java/com/google/gerrit/server/restapi/project/Index.java b/java/com/google/gerrit/server/restapi/project/Index.java
index bc58b23..6a95b62 100644
--- a/java/com/google/gerrit/server/restapi/project/Index.java
+++ b/java/com/google/gerrit/server/restapi/project/Index.java
@@ -60,7 +60,7 @@
     reindex(rsrc.getNameKey(), input.async);
     if (Boolean.TRUE.equals(input.indexChildren)) {
       for (ProjectInfo child : listChildProjectsProvider.get().withRecursive(true).apply(rsrc)) {
-        reindex(new Project.NameKey(child.name), input.async);
+        reindex(Project.nameKey(child.name), input.async);
       }
 
       response += " (indexing children recursively)";
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index a0d2528..e26317d 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -127,7 +127,7 @@
   }
 
   @Override
-  public List<BranchInfo> apply(ProjectResource rsrc)
+  public ImmutableList<BranchInfo> apply(ProjectResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     rsrc.getProjectState().checkStatePermitsRead();
     return new RefFilter<BranchInfo>(Constants.R_HEADS)
@@ -278,7 +278,8 @@
       info.actions.put(d.getId(), new ActionInfo(d));
     }
 
-    List<WebLinkInfo> links = webLinks.getBranchLinks(projectState.getName(), ref.getName());
+    ImmutableList<WebLinkInfo> links =
+        webLinks.getBranchLinks(projectState.getName(), ref.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 3879720..a846ef8 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -85,10 +85,7 @@
     PermissionBackend.WithUser currentUser = permissionBackend.currentUser();
     return queryProvider.get().withQuery("parent:" + parent.get()).withLimit(limit).apply().stream()
         .filter(
-            p ->
-                currentUser
-                    .project(new Project.NameKey(p.name))
-                    .testOrFalse(ProjectPermission.ACCESS))
+            p -> currentUser.project(Project.nameKey(p.name)).testOrFalse(ProjectPermission.ACCESS))
         .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 4f3dbb7..f12e4d8 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -505,7 +505,7 @@
           continue;
         }
 
-        List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
+        ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
         info.webLinks = links.isEmpty() ? null : links;
 
         if (stdout == null || format.isJson()) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 98c8c61..6b38a2e 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -116,7 +116,7 @@
   }
 
   @Override
-  public List<TagInfo> apply(ProjectResource resource)
+  public ImmutableList<TagInfo> apply(ProjectResource resource)
       throws IOException, ResourceNotFoundException, RestApiException, PermissionBackendException {
     resource.getProjectState().checkStatePermitsRead();
 
@@ -182,7 +182,7 @@
           perm.testOrFalse(RefPermission.DELETE) && projectState.statePermitsWrite() ? true : null;
     }
 
-    List<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
+    ImmutableList<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
     if (object instanceof RevTag) {
       // Annotated or signed tag
       RevTag tag = (RevTag) object;
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 31c90e5..875dcfb 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -138,7 +138,7 @@
       throws IOException, PermissionBackendException, ResourceConflictException {
     id = ProjectUtil.sanitizeProjectName(id);
 
-    Project.NameKey nameKey = new Project.NameKey(id);
+    Project.NameKey nameKey = Project.nameKey(id);
     ProjectState state = projectCache.checkedGet(nameKey);
     if (state == null) {
       return null;
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 5277cf8..1504d6c 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -117,7 +117,7 @@
           identifiedUser.get(),
           config,
           rsrc.getNameKey(),
-          input.parent == null ? null : new Project.NameKey(input.parent),
+          input.parent == null ? null : Project.nameKey(input.parent),
           !checkedAdmin);
 
       if (!Strings.isNullOrEmpty(input.message)) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 12aaf76..e18066e 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -154,7 +154,7 @@
 
     newParent = Strings.emptyToNull(newParent);
     if (newParent != null) {
-      ProjectState parent = cache.get(new Project.NameKey(newParent));
+      ProjectState parent = cache.get(Project.nameKey(newParent));
       if (parent == null) {
         throw new UnprocessableEntityException("parent project " + newParent + " not found");
       }
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 2be6c19..8401c1d 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -125,7 +125,7 @@
   private static List<PatchSetApproval> getApprovalsForLabel(
       List<PatchSetApproval> approvals, LabelType t) {
     return approvals.stream()
-        .filter(input -> input.getLabel().equals(t.getLabelId().get()))
+        .filter(input -> input.label().equals(t.getLabelId().get()))
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 592c269c..4695800 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -79,7 +79,7 @@
 
     Account.Id uploader;
     try {
-      uploader = cd.currentPatchSet().getUploader();
+      uploader = cd.currentPatchSet().uploader();
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_UPLOADER);
       return singletonRuleError(E_UNABLE_TO_FETCH_UPLOADER);
@@ -155,7 +155,7 @@
   static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
       Collection<PatchSetApproval> approvals, Account.Id user) {
     return approvals.stream()
-        .filter(input -> input.getValue() < 0 || !input.getAccountId().equals(user))
+        .filter(input -> input.value() < 0 || !input.accountId().equals(user))
         .collect(toImmutableList());
   }
 
@@ -163,7 +163,7 @@
   static Collection<PatchSetApproval> filterApprovalsByLabel(
       Collection<PatchSetApproval> approvals, LabelType t) {
     return approvals.stream()
-        .filter(input -> input.getLabelId().get().equals(t.getLabelId().get()))
+        .filter(input -> input.labelId().get().equals(t.getLabelId().get()))
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 9cde54c..c036c86 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -540,7 +540,7 @@
     if (status instanceof StructureTerm && status.arity() == 1) {
       Term who = status.arg(0);
       if (isUser(who)) {
-        label.appliedBy = new Account.Id(((IntegerTerm) who.arg(0)).intValue());
+        label.appliedBy = Account.id(((IntegerTerm) who.arg(0)).intValue());
       } else {
         throw new UserTermExpected(label);
       }
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 9bae091..40f0ff5 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -42,7 +42,6 @@
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 
@@ -96,9 +95,8 @@
           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);
+          PatchListKey plKey = PatchListKey.againstDefaultBase(ps.commitId(), ws);
           PatchList patchList;
           try {
             patchList = plCache.get(plKey, project);
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 1b48b2c..4877eed 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -218,7 +218,7 @@
                     + "(account_id, change_id, patch_set_id, file_name) VALUES "
                     + "(?, ?, ?, ?)")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       stmt.setString(4, path);
       stmt.executeUpdate();
@@ -246,7 +246,7 @@
                     + "(?, ?, ?, ?)")) {
       for (String path : paths) {
         stmt.setInt(1, accountId.get());
-        stmt.setInt(2, psId.getParentKey().get());
+        stmt.setInt(2, psId.changeId().get());
         stmt.setInt(3, psId.get());
         stmt.setString(4, path);
         stmt.addBatch();
@@ -270,7 +270,7 @@
                     + "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(2, psId.changeId().get());
       stmt.setInt(3, psId.get());
       stmt.setString(4, path);
       stmt.executeUpdate();
@@ -286,7 +286,7 @@
             con.prepareStatement(
                 "DELETE FROM account_patch_reviews "
                     + "WHERE change_id = ? AND patch_set_id = ?")) {
-      stmt.setInt(1, psId.getParentKey().get());
+      stmt.setInt(1, psId.changeId().get());
       stmt.setInt(2, psId.get());
       stmt.executeUpdate();
     } catch (SQLException e) {
@@ -306,11 +306,11 @@
                     + "AND APR1.change_id = APR2.change_id "
                     + "AND patch_set_id <= ?)")) {
       stmt.setInt(1, accountId.get());
-      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(2, psId.changeId().get());
       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 = PatchSet.id(psId.changeId(), rs.getInt("patch_set_id"));
           ImmutableSet.Builder<String> builder = ImmutableSet.builder();
           do {
             builder.add(rs.getString("file_name"));
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 0a5823a..e7f3897 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -218,8 +218,8 @@
   private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference) {
     int next = seqs.nextGroupId();
     return InternalGroupCreation.builder()
-        .setNameKey(new AccountGroup.NameKey(groupReference.getName()))
-        .setId(new AccountGroup.Id(next))
+        .setNameKey(AccountGroup.nameKey(groupReference.getName()))
+        .setId(AccountGroup.id(next))
         .setGroupUUID(groupReference.getUUID())
         .build();
   }
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 63837b2..be56782 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.schema.testing;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -151,8 +152,8 @@
 
     Set<String> subsections1 = config1.getSubsections(section);
     Set<String> subsections2 = config2.getSubsections(section);
-    assertThat(subsections1)
-        .named("section \"%s\"", section)
+    assertWithMessage("section \"%s\"", section)
+        .that(subsections1)
         .containsExactlyElementsIn(subsections2);
 
     subsections1.forEach(s -> assertSubsectionEquivalent(config1, config2, section, s));
@@ -163,12 +164,12 @@
     Set<String> subsectionNames1 = config1.getNames(section, subsection);
     Set<String> subsectionNames2 = config2.getNames(section, subsection);
     String name = String.format("subsection \"%s\" of section \"%s\"", subsection, section);
-    assertThat(subsectionNames1).named(name).containsExactlyElementsIn(subsectionNames2);
+    assertWithMessage(name).that(subsectionNames1).containsExactlyElementsIn(subsectionNames2);
 
     subsectionNames1.forEach(
         n ->
-            assertThat(config1.getStringList(section, subsection, n))
-                .named(name)
+            assertWithMessage(name)
+                .that(config1.getStringList(section, subsection, n))
                 .asList()
                 .containsExactlyElementsIn(config2.getStringList(section, subsection, n)));
   }
diff --git a/java/com/google/gerrit/server/submit/ChangeSet.java b/java/com/google/gerrit/server/submit/ChangeSet.java
index e721be41ab..b6dbbb6 100644
--- a/java/com/google/gerrit/server/submit/ChangeSet.java
+++ b/java/com/google/gerrit/server/submit/ChangeSet.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -76,8 +76,8 @@
     return changeData;
   }
 
-  public ListMultimap<Branch.NameKey, ChangeData> changesByBranch() {
-    ListMultimap<Branch.NameKey, ChangeData> ret =
+  public ListMultimap<BranchNameKey, ChangeData> changesByBranch() {
+    ListMultimap<BranchNameKey, ChangeData> ret =
         MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeData cd : changeData.values()) {
       ret.put(cd.change().getDest(), cd);
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index 8ff3cc5..4d57591 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -162,7 +162,7 @@
               ctx.getUpdate(psId),
               psId,
               newCommit,
-              prevPs != null ? prevPs.getGroups() : ImmutableList.of(),
+              prevPs != null ? prevPs.groups() : ImmutableList.of(),
               null,
               null);
       ctx.getChange().setCurrentPatchSet(patchSetInfo);
diff --git a/java/com/google/gerrit/server/submit/CommitMergeStatus.java b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
index 12172dd..2ca0ec5 100644
--- a/java/com/google/gerrit/server/submit/CommitMergeStatus.java
+++ b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
@@ -49,7 +49,8 @@
           + "Please rebase the change locally and upload the rebased commit for review."),
 
   SKIPPED_IDENTICAL_TREE(
-      "Marking change merged without cherry-picking to branch, as the resulting commit would be empty."),
+      "Marking change merged without cherry-picking to branch, as the resulting commit would be"
+          + " empty."),
 
   MISSING_DEPENDENCY("Depends on change that was not submitted."),
 
@@ -102,24 +103,22 @@
           commit, otherCommit, caller != null ? caller.getLoggableName() : "<user-not-available>");
     } else if (changes.size() == 1) {
       ChangeData cd = changes.get(0);
-      if (cd.currentPatchSet().getRevision().get().equals(otherCommit)) {
+      if (cd.currentPatchSet().commitId().name().equals(otherCommit)) {
         return String.format(
             "Commit %s depends on commit %s of change %d which cannot be merged.",
             commit, otherCommit, cd.getId().get());
       }
       Optional<PatchSet> patchSet =
-          cd.patchSets().stream()
-              .filter(ps -> ps.getRevision().get().equals(otherCommit))
-              .findAny();
+          cd.patchSets().stream().filter(ps -> ps.commitId().name().equals(otherCommit)).findAny();
       if (patchSet.isPresent()) {
         return String.format(
             "Commit %s depends on commit %s, which is outdated patch set %d of change %d."
                 + " The latest patch set is %d.",
             commit,
             otherCommit,
-            patchSet.get().getId().get(),
+            patchSet.get().id().get(),
             cd.getId().get(),
-            cd.currentPatchSet().getId().get());
+            cd.currentPatchSet().id().get());
       }
       // should not happen, fall-back to default message
       return String.format(
diff --git a/java/com/google/gerrit/server/submit/GitModules.java b/java/com/google/gerrit/server/submit/GitModules.java
index d49f53f..1770c4a 100644
--- a/java/com/google/gerrit/server/submit/GitModules.java
+++ b/java/com/google/gerrit/server/submit/GitModules.java
@@ -16,7 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.config.CanonicalWebUrl;
@@ -45,7 +45,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    GitModules create(Branch.NameKey project, MergeOpRepoManager m);
+    GitModules create(BranchNameKey project, MergeOpRepoManager m);
   }
 
   private static final String GIT_MODULES = ".gitmodules";
@@ -55,16 +55,16 @@
   @Inject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      @Assisted Branch.NameKey branch,
+      @Assisted BranchNameKey branch,
       @Assisted MergeOpRepoManager orm)
       throws IOException {
-    Project.NameKey project = branch.getParentKey();
+    Project.NameKey project = branch.project();
     logger.atFine().log("Loading .gitmodules of %s for project %s", branch, project);
     try {
       OpenRepo or = orm.getRepo(project);
-      ObjectId id = or.repo.resolve(branch.get());
+      ObjectId id = or.repo.resolve(branch.branch());
       if (id == null) {
-        throw new IOException("Cannot open branch " + branch.get());
+        throw new IOException("Cannot open branch " + branch.branch());
       }
       RevCommit commit = or.rw.parseCommit(id);
 
@@ -80,7 +80,7 @@
         config = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
       } catch (ConfigInvalidException e) {
         throw new IOException(
-            "Could not read .gitmodules of super project: " + branch.getParentKey(), e);
+            "Could not read .gitmodules of super project: " + branch.project(), e);
       }
       subscriptions =
           new SubmoduleSectionParser(config, canonicalWebUrl, branch).parseAllSections();
@@ -89,7 +89,7 @@
     }
   }
 
-  Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
+  Collection<SubmoduleSubscription> subscribedTo(BranchNameKey src) {
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     for (SubmoduleSubscription s : subscriptions) {
       if (s.getSubmodule().equals(src)) {
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 831a8b8..bb6a2e5 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -53,7 +53,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -75,12 +74,12 @@
 
   @AutoValue
   abstract static class QueryKey {
-    private static QueryKey create(Branch.NameKey branch, Iterable<String> hashes) {
+    private static QueryKey create(BranchNameKey branch, Iterable<String> hashes) {
       return new AutoValue_LocalMergeSuperSetComputation_QueryKey(
           branch, ImmutableSet.copyOf(hashes));
     }
 
-    abstract Branch.NameKey branch();
+    abstract BranchNameKey branch();
 
     abstract ImmutableSet<String> hashes();
   }
@@ -88,7 +87,7 @@
   private final PermissionBackend permissionBackend;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
-  private final Map<Branch.NameKey, Optional<RevCommit>> heads;
+  private final Map<BranchNameKey, Optional<RevCommit>> heads;
   private final ProjectCache projectCache;
   private final ChangeIsVisibleToPredicate changeIsVisibleToPredicate;
 
@@ -115,10 +114,10 @@
 
     // 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 =
+    ImmutableListMultimap<BranchNameKey, ChangeData> bc =
         byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges()));
-    for (Branch.NameKey b : bc.keySet()) {
-      OpenRepo or = getRepo(orm, b.getParentKey());
+    for (BranchNameKey b : bc.keySet()) {
+      OpenRepo or = getRepo(orm, b.project());
       List<RevCommit> visibleCommits = new ArrayList<>();
       List<RevCommit> nonVisibleCommits = new ArrayList<>();
       for (ChangeData cd : bc.get(b)) {
@@ -135,8 +134,7 @@
         }
 
         // Get the underlying git commit object
-        String objIdStr = cd.currentPatchSet().getRevision().get();
-        RevCommit commit = or.rw.parseCommit(ObjectId.fromString(objIdStr));
+        RevCommit commit = or.rw.parseCommit(cd.currentPatchSet().commitId());
 
         // Always include the input, even if merged. This allows
         // SubmitStrategyOp to correct the situation later, assuming it gets
@@ -160,9 +158,9 @@
     return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
-  private static ImmutableListMultimap<Branch.NameKey, ChangeData> byBranch(
+  private static ImmutableListMultimap<BranchNameKey, ChangeData> byBranch(
       Iterable<ChangeData> changes) {
-    ImmutableListMultimap.Builder<Branch.NameKey, ChangeData> builder =
+    ImmutableListMultimap.Builder<BranchNameKey, ChangeData> builder =
         ImmutableListMultimap.builder();
     for (ChangeData cd : changes) {
       builder.put(cd.change().getDest(), cd);
@@ -211,7 +209,7 @@
   }
 
   private ChangeSet byCommitsOnBranchNotMerged(
-      OpenRepo or, Branch.NameKey branch, Set<String> visibleHashes, Set<String> nonVisibleHashes)
+      OpenRepo or, BranchNameKey branch, Set<String> visibleHashes, Set<String> nonVisibleHashes)
       throws IOException {
     List<ChangeData> potentiallyVisibleChanges =
         byCommitsOnBranchNotMerged(or, branch, visibleHashes);
@@ -229,7 +227,7 @@
   }
 
   private ImmutableList<ChangeData> byCommitsOnBranchNotMerged(
-      OpenRepo or, Branch.NameKey branch, Set<String> hashes) throws IOException {
+      OpenRepo or, BranchNameKey branch, Set<String> hashes) throws IOException {
     if (hashes.isEmpty()) {
       return ImmutableList.of();
     }
@@ -245,7 +243,7 @@
   }
 
   private Set<String> walkChangesByHashes(
-      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, Branch.NameKey b)
+      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
     or.rw.reset();
@@ -269,10 +267,10 @@
     return destHashes;
   }
 
-  private void markHeadUninteresting(OpenRepo or, Branch.NameKey b) throws IOException {
+  private void markHeadUninteresting(OpenRepo or, BranchNameKey b) throws IOException {
     Optional<RevCommit> head = heads.get(b);
     if (head == null) {
-      Ref ref = or.repo.getRefDatabase().exactRef(b.get());
+      Ref ref = or.repo.getRefDatabase().exactRef(b.branch());
       head = ref != null ? Optional.of(or.rw.parseCommit(ref.getObjectId())) : Optional.empty();
       heads.put(b, head);
     }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 5a38adb..06c52c7 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -48,7 +48,7 @@
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -119,7 +119,7 @@
 
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
-    private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
+    private final ImmutableSetMultimap<BranchNameKey, Change.Id> byBranch;
     private final Map<Change.Id, CodeReviewCommit> commits;
     private final ListMultimap<Change.Id, String> problems;
     private final boolean allowClosed;
@@ -128,7 +128,7 @@
       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<BranchNameKey, Change.Id> bb = ImmutableSetMultimap.builder();
       for (ChangeData cd : cs.changes()) {
         bb.put(cd.change().getDest(), cd.getId());
       }
@@ -142,7 +142,7 @@
       return changes.keySet();
     }
 
-    public ImmutableSet<Change.Id> getChangeIds(Branch.NameKey branch) {
+    public ImmutableSet<Change.Id> getChangeIds(BranchNameKey branch) {
       return byBranch.get(branch);
     }
 
@@ -296,7 +296,7 @@
       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()));
+              cd.getId(), patchSet.id(), cd.change().getProject().get()));
     }
 
     for (SubmitRecord record : results) {
@@ -318,7 +318,7 @@
           throw new IllegalStateException(
               String.format(
                   "Unexpected SubmitRecord status %s for %s in %s",
-                  record.status, patchSet.getId().getId(), cd.change().getProject().get()));
+                  record.status, patchSet.id().getId(), cd.change().getProject().get()));
       }
     }
     throw new IllegalStateException();
@@ -573,18 +573,18 @@
       throws IntegrationException, RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logger.atFine().log("Beginning merge attempt on %s", cs);
-    Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
+    Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
 
-    ListMultimap<Branch.NameKey, ChangeData> cbb;
+    ListMultimap<BranchNameKey, ChangeData> cbb;
     try {
       cbb = cs.changesByBranch();
     } catch (StorageException e) {
       throw new IntegrationException("Error reading changes to submit", e);
     }
-    Set<Branch.NameKey> branches = cbb.keySet();
+    Set<BranchNameKey> branches = cbb.keySet();
 
-    for (Branch.NameKey branch : branches) {
-      OpenRepo or = openRepo(branch.getParentKey());
+    for (BranchNameKey branch : branches) {
+      OpenRepo or = openRepo(branch.project());
       if (or != null) {
         toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
       }
@@ -641,14 +641,14 @@
   }
 
   private List<SubmitStrategy> getSubmitStrategies(
-      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+      Map<BranchNameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
       throws IntegrationException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
-    Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
+    Set<BranchNameKey> 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());
+    for (BranchNameKey branch : allBranches) {
+      OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
         BranchBatch submitting = toSubmit.get(branch);
         logger.atFine().log("adding ops for branch batch %s", submitting);
@@ -769,40 +769,32 @@
       }
 
       PatchSet ps;
-      Branch.NameKey destBranch = chg.getDest();
+      BranchNameKey destBranch = chg.getDest();
       try {
         ps = cd.currentPatchSet();
       } catch (StorageException e) {
         commitStatus.logProblem(changeId, e);
         continue;
       }
-      if (ps == null || ps.getRevision() == null || ps.getRevision().get() == null) {
-        commitStatus.logProblem(changeId, "Missing patch set or revision on change");
+      if (ps == null) {
+        commitStatus.logProblem(changeId, "Missing patch set on change");
         continue;
       }
 
-      String idstr = ps.getRevision().get();
-      ObjectId id;
-      try {
-        id = ObjectId.fromString(idstr);
-      } catch (IllegalArgumentException e) {
-        commitStatus.logProblem(changeId, e);
-        continue;
-      }
-
-      if (!revisions.containsEntry(id, ps.getId())) {
-        if (revisions.containsValue(ps.getId())) {
+      ObjectId id = ps.commitId();
+      if (!revisions.containsEntry(id, ps.id())) {
+        if (revisions.containsValue(ps.id())) {
           // TODO This is actually an error, the patch set ref exists but points to a revision that
           // is different from the revision that we have stored for the patch set in the change
           // meta data.
           commitStatus.logProblem(
               changeId,
               "Revision "
-                  + idstr
+                  + id.name()
                   + " of patch set "
-                  + ps.getPatchSetId()
+                  + ps.number()
                   + " does not match the revision of the patch set ref "
-                  + ps.getId().toRefName());
+                  + ps.id().toRefName());
           continue;
         }
 
@@ -813,11 +805,11 @@
         commitStatus.logProblem(
             changeId,
             "Patch set ref "
-                + ps.getId().toRefName()
+                + ps.id().toRefName()
                 + " not found. Expected patch set ref of "
-                + ps.getPatchSetId()
+                + ps.number()
                 + " to point to revision "
-                + idstr);
+                + id.name());
         continue;
       }
 
@@ -830,13 +822,12 @@
       }
 
       commit.setNotes(notes);
-      commit.setPatchsetId(ps.getId());
+      commit.setPatchsetId(ps.id());
       commitStatus.put(commit);
 
       MergeValidators mergeValidators = mergeValidatorsFactory.create();
       try {
-        mergeValidators.validatePreMerge(
-            or.repo, commit, or.project, destBranch, ps.getId(), caller);
+        mergeValidators.validatePreMerge(or.repo, commit, or.project, destBranch, ps.id(), caller);
       } catch (MergeValidationException mve) {
         commitStatus.problem(changeId, mve.getMessage());
         continue;
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 764aca8..d985b7f 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -18,7 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.Maps;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
@@ -67,7 +67,7 @@
     BatchUpdate update;
 
     private final ObjectReader reader;
-    private final Map<Branch.NameKey, OpenBranch> branches;
+    private final Map<BranchNameKey, OpenBranch> branches;
 
     private OpenRepo(Repository repo, ProjectState project) {
       this.repo = repo;
@@ -84,7 +84,7 @@
       branches = Maps.newHashMapWithExpectedSize(1);
     }
 
-    OpenBranch getBranch(Branch.NameKey branch) throws IntegrationException {
+    OpenBranch getBranch(BranchNameKey branch) throws IntegrationException {
       OpenBranch ob = branches.get(branch);
       if (ob == null) {
         ob = new OpenBranch(this, branch);
@@ -134,13 +134,13 @@
     final CodeReviewCommit oldTip;
     MergeTip mergeTip;
 
-    OpenBranch(OpenRepo or, Branch.NameKey name) throws IntegrationException {
+    OpenBranch(OpenRepo or, BranchNameKey name) throws IntegrationException {
       try {
-        update = or.repo.updateRef(name.get());
+        update = or.repo.updateRef(name.branch());
         if (update.getOldObjectId() != null) {
           oldTip = or.rw.parseCommit(update.getOldObjectId());
-        } else if (Objects.equals(or.repo.getFullBranch(), name.get())
-            || Objects.equals(RefNames.REFS_CONFIG, name.get())) {
+        } else if (Objects.equals(or.repo.getFullBranch(), name.branch())
+            || Objects.equals(RefNames.REFS_CONFIG, name.branch())) {
           oldTip = null;
           update.setExpectedOldObjectId(ObjectId.zeroId());
         } else {
diff --git a/java/com/google/gerrit/server/submit/RebaseSorter.java b/java/com/google/gerrit/server/submit/RebaseSorter.java
index 829ee9c..21ab6b7 100644
--- a/java/com/google/gerrit/server/submit/RebaseSorter.java
+++ b/java/com/google/gerrit/server/submit/RebaseSorter.java
@@ -16,7 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -108,7 +108,7 @@
     return sorted;
   }
 
-  private boolean isAlreadyMerged(CodeReviewCommit commit, Branch.NameKey dest) throws IOException {
+  private boolean isAlreadyMerged(CodeReviewCommit commit, BranchNameKey dest) throws IOException {
     try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
       mirw.reset();
       mirw.markStart(commit);
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 342ae69..b8fb067 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -234,7 +234,7 @@
                 ctx.getUpdate(newPatchSetId),
                 newPatchSetId,
                 newCommit,
-                prevPs != null ? prevPs.getGroups() : ImmutableList.of(),
+                prevPs != null ? prevPs.groups() : ImmutableList.of(),
                 null,
                 null);
       }
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index 391d956..3a59a45 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -21,7 +21,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -112,7 +112,7 @@
       SubmitType submitType,
       Repository repo,
       CodeReviewRevWalk rw,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       ObjectId tip,
       ObjectId toMerge,
       Set<RevCommit> alreadyAccepted)
@@ -155,10 +155,10 @@
     }
   }
 
-  private ProjectState getProject(Branch.NameKey branch) throws NoSuchProjectException {
-    ProjectState p = projectCache.get(branch.getParentKey());
+  private ProjectState getProject(BranchNameKey branch) throws NoSuchProjectException {
+    ProjectState p = projectCache.get(branch.project());
     if (p == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
+      throw new NoSuchProjectException(branch.project());
     }
     return p;
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index dc221f8..73cbc8f 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -82,7 +82,7 @@
     interface Factory {
       Arguments create(
           SubmitType submitType,
-          Branch.NameKey destBranch,
+          BranchNameKey destBranch,
           CommitStatus commitStatus,
           CodeReviewRevWalk rw,
           IdentifiedUser caller,
@@ -114,7 +114,7 @@
     final ProjectConfig.Factory projectConfigFactory;
     final SetPrivateOp.Factory setPrivateOpFactory;
 
-    final Branch.NameKey destBranch;
+    final BranchNameKey destBranch;
     final CodeReviewRevWalk rw;
     final CommitStatus commitStatus;
     final IdentifiedUser caller;
@@ -152,7 +152,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ProjectConfig.Factory projectConfigFactory,
         SetPrivateOp.Factory setPrivateOpFactory,
-        @Assisted Branch.NameKey destBranch,
+        @Assisted BranchNameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
         @Assisted IdentifiedUser caller,
@@ -197,8 +197,8 @@
 
       this.project =
           requireNonNull(
-              projectCache.get(destBranch.getParentKey()),
-              () -> String.format("project not found: %s", destBranch.getParentKey()));
+              projectCache.get(destBranch.project()),
+              () -> String.format("project not found: %s", destBranch.project()));
       this.mergeSorter =
           new MergeSorter(caller, rw, alreadyAccepted, canMergeFlag, queryProvider, incoming);
       this.rebaseSorter =
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 30326f7..e2e4991 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -17,7 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -48,7 +48,7 @@
       RevFlag canMergeFlag,
       Set<RevCommit> alreadyAccepted,
       Set<CodeReviewCommit> incoming,
-      Branch.NameKey destBranch,
+      BranchNameKey destBranch,
       IdentifiedUser caller,
       MergeTip mergeTip,
       CommitStatus commitStatus,
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
index 782cd7b..3d6aa55 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -129,7 +129,7 @@
 
         case ALREADY_MERGED:
           // Already an ancestor of tip.
-          alreadyMerged.add(commit.getPatchsetId().getParentKey());
+          alreadyMerged.add(commit.getPatchsetId().changeId());
           break;
 
         case PATH_CONFLICT:
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 73bcc09..b1c7dd9 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -20,12 +20,11 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.base.Function;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.LabelId;
@@ -89,12 +88,12 @@
     return toMerge;
   }
 
-  protected final Branch.NameKey getDest() {
+  protected final BranchNameKey getDest() {
     return toMerge.change().getDest();
   }
 
   protected final Project.NameKey getProject() {
-    return getDest().getParentKey();
+    return getDest().project();
   }
 
   @Override
@@ -132,14 +131,15 @@
     // 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());
+        new ReceiveCommand(
+            firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().branch());
     ctx.addRefUpdate(command);
     args.submoduleOp.addBranchTip(getDest(), tipAfter);
   }
 
   private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit)
       throws IntegrationException {
-    String refName = getDest().get();
+    String refName = getDest().branch();
     if (RefNames.REFS_CONFIG.equals(refName)) {
       logger.atFine().log("Loading new configuration from %s", RefNames.REFS_CONFIG);
       try {
@@ -251,7 +251,7 @@
                 args.psUtil.get(ctx.getNotes(), oldPsId),
                 () -> String.format("missing old patch set %s", oldPsId));
       } else {
-        PatchSet.Id n = newPatchSet.getId();
+        PatchSet.Id n = newPatchSet.id();
         checkState(
             !n.equals(oldPsId) && n.equals(newPsId),
             "current patch was %s and is now %s, but updateChangeImpl returned"
@@ -311,7 +311,7 @@
     // 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(alreadyMergedCommit);
+        prevPs != null ? prevPs.groups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
     return args.psUtil.insert(
         ctx.getRevWalk(), ctx.getUpdate(psId), psId, alreadyMergedCommit, groups, null, null);
   }
@@ -333,7 +333,7 @@
     // approvals as well.
     if (!newPsId.equals(oldPsId)) {
       saveApprovals(normalized, newPsUpdate, true);
-      submitter = convertPatchSet(newPsId).apply(submitter);
+      submitter = submitter.copyWithPatchSet(newPsId);
     }
   }
 
@@ -344,12 +344,13 @@
     for (PatchSetApproval psa :
         args.approvalsUtil.byPatchSet(
             ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-      byKey.put(psa.getKey(), psa);
+      byKey.put(psa.key(), psa);
     }
 
     submitter =
-        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
-    byKey.put(submitter.getKey(), submitter);
+        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen())
+            .build();
+    byKey.put(submitter.key(), submitter);
 
     // Flatten out existing approvals for this patch set based upon the current
     // permissions. Once the change is closed the approvals are not updated at
@@ -358,7 +359,7 @@
     // permissions get modified in the future, historical records stay accurate.
     LabelNormalizer.Result normalized =
         args.labelNormalizer.normalize(ctx.getNotes(), byKey.values());
-    update.putApproval(submitter.getLabel(), submitter.getValue());
+    update.putApproval(submitter.label(), submitter.value());
     saveApprovals(normalized, update, false);
     return normalized;
   }
@@ -366,10 +367,10 @@
   private void saveApprovals(
       LabelNormalizer.Result normalized, ChangeUpdate update, boolean includeUnchanged) {
     for (PatchSetApproval psa : normalized.updated()) {
-      update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+      update.putApprovalFor(psa.accountId(), psa.label(), psa.value());
     }
     for (PatchSetApproval psa : normalized.deleted()) {
-      update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+      update.removeApprovalFor(psa.accountId(), psa.label());
     }
 
     // TODO(dborowitz): Don't use a label in NoteDb; just check when status
@@ -377,25 +378,15 @@
     for (PatchSetApproval psa : normalized.unchanged()) {
       if (includeUnchanged || psa.isLegacySubmit()) {
         logger.atFine().log("Adding submit label %s", psa);
-        update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+        update.putApprovalFor(psa.accountId(), psa.label(), psa.value());
       }
     }
   }
 
-  private static Function<PatchSetApproval, PatchSetApproval> convertPatchSet(
-      final PatchSet.Id psId) {
-    return psa -> {
-      if (psa.getPatchSetId().equals(psId)) {
-        return psa;
-      }
-      return new PatchSetApproval(psId, psa);
-    };
-  }
-
   private String getByAccountName() {
     requireNonNull(submitter, "getByAccountName called before submitter populated");
     Optional<Account> account =
-        args.accountCache.get(submitter.getAccountId()).map(AccountState::getAccount);
+        args.accountCache.get(submitter.accountId()).map(AccountState::getAccount);
     if (account.isPresent() && account.get().getFullName() != null) {
       return " by " + account.get().getFullName();
     }
@@ -483,7 +474,7 @@
           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())) {
+      if (RefNames.REFS_CONFIG.equals(getDest().branch())) {
         args.projectCache.evict(getProject());
         ProjectState p = args.projectCache.get(getProject());
         try (Repository git = args.repoManager.openRepository(getProject())) {
@@ -498,7 +489,7 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(ctx.getProject(), getId(), submitter.getAccountId(), ctx.getNotify(getId()))
+          .create(ctx.getProject(), getId(), submitter.accountId(), ctx.getNotify(getId()))
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
@@ -507,7 +498,7 @@
       args.changeMerged.fire(
           updatedChange,
           mergedPatchSet,
-          args.accountCache.get(submitter.getAccountId()).orElse(null),
+          args.accountCache.get(submitter.accountId()).orElse(null),
           args.mergeTip.getCurrentTip().name(),
           ctx.getWhen());
     }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 9b418db..7fc47dc 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
@@ -79,9 +79,9 @@
 
   /** Only used for branches without code review changes */
   public class GitlinkOp implements RepoOnlyOp {
-    private final Branch.NameKey branch;
+    private final BranchNameKey branch;
 
-    GitlinkOp(Branch.NameKey branch) {
+    GitlinkOp(BranchNameKey branch) {
       this.branch = branch;
     }
 
@@ -89,7 +89,7 @@
     public void updateRepo(RepoContext ctx) throws Exception {
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
-        ctx.addRefUpdate(c.getParent(0), c, branch.get());
+        ctx.addRefUpdate(c.getParent(0), c, branch.branch());
         addBranchTip(branch, c);
       }
     }
@@ -114,7 +114,7 @@
       this.projectCache = projectCache;
     }
 
-    public SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm)
+    public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleException {
       return new SubmoduleOp(
           gitmodulesFactory, serverIdent.get(), cfg, projectCache, updatedBranches, orm);
@@ -129,41 +129,41 @@
   private final long maxCombinedCommitMessageSize;
   private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
-  private final Map<Branch.NameKey, GitModules> branchGitModules;
+  private final Map<BranchNameKey, GitModules> branchGitModules;
 
   /** Branches updated as part of the enclosing submit or push batch. */
-  private final ImmutableSet<Branch.NameKey> updatedBranches;
+  private final ImmutableSet<BranchNameKey> updatedBranches;
 
   /**
    * Current branch tips, taking into account commits created during the submit process as well as
    * submodule updates produced by this class.
    */
-  private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
+  private final Map<BranchNameKey, CodeReviewCommit> branchTips;
 
   /**
    * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
    * which are subscribed to by some superproject.
    */
-  private final Set<Branch.NameKey> affectedBranches;
+  private final Set<BranchNameKey> affectedBranches;
 
   /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
-  private final ImmutableSet<Branch.NameKey> sortedBranches;
+  private final ImmutableSet<BranchNameKey> sortedBranches;
 
   /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
-  private final SetMultimap<Branch.NameKey, SubmoduleSubscription> targets;
+  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
 
   /**
    * Multimap of superproject name to all branch names within that superproject which have submodule
    * subscriptions.
    */
-  private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
+  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
 
   private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
       PersonIdent myIdent,
       Config cfg,
       ProjectCache projectCache,
-      Set<Branch.NameKey> updatedBranches,
+      Set<BranchNameKey> updatedBranches,
       MergeOpRepoManager orm)
       throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
@@ -214,15 +214,15 @@
   //
   // In addition to improving readability, this approach has the advantage of making (1) and (2)
   // testable using small tests.
-  private ImmutableSet<Branch.NameKey> calculateSubscriptionMaps() throws SubmoduleException {
+  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps() throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
       logger.atFine().log("Updating superprojects disabled");
       return null;
     }
 
     logger.atFine().log("Calculating superprojects - submodules map");
-    LinkedHashSet<Branch.NameKey> allVisited = new LinkedHashSet<>();
-    for (Branch.NameKey updatedBranch : updatedBranches) {
+    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+    for (BranchNameKey updatedBranch : updatedBranches) {
       if (allVisited.contains(updatedBranch)) {
         continue;
       }
@@ -240,9 +240,9 @@
   }
 
   private void searchForSuperprojects(
-      Branch.NameKey current,
-      LinkedHashSet<Branch.NameKey> currentVisited,
-      LinkedHashSet<Branch.NameKey> allVisited)
+      BranchNameKey current,
+      LinkedHashSet<BranchNameKey> currentVisited,
+      LinkedHashSet<BranchNameKey> allVisited)
       throws SubmoduleException {
     logger.atFine().log("Now processing %s", current);
 
@@ -261,10 +261,10 @@
       Collection<SubmoduleSubscription> subscriptions =
           superProjectSubscriptionsForSubmoduleBranch(current);
       for (SubmoduleSubscription sub : subscriptions) {
-        Branch.NameKey superBranch = sub.getSuperProject();
+        BranchNameKey superBranch = sub.getSuperProject();
         searchForSuperprojects(superBranch, currentVisited, allVisited);
         targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.getParentKey(), superBranch);
+        branchesByProject.put(superBranch.project(), superBranch);
         affectedBranches.add(superBranch);
         affectedBranches.add(sub.getSubmodule());
       }
@@ -303,31 +303,33 @@
     return sb.toString();
   }
 
-  private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src, SubscribeSection s)
+  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
       throws IOException {
-    Collection<Branch.NameKey> ret = new HashSet<>();
+    Collection<BranchNameKey> ret = new HashSet<>();
     logger.atFine().log("Inspecting SubscribeSection %s", s);
     for (RefSpec r : s.getMatchingRefSpecs()) {
       logger.atFine().log("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.get())) {
+      if (!r.matchSource(src.branch())) {
         continue;
       }
       if (r.isWildcard()) {
         // refs/heads/*[:refs/somewhere/*]
-        ret.add(new Branch.NameKey(s.getProject(), r.expandFromSource(src.get()).getDestination()));
+        ret.add(
+            BranchNameKey.create(
+                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
       } else {
         // e.g. refs/heads/master[:refs/heads/stable]
         String dest = r.getDestination();
         if (dest == null) {
           dest = r.getSource();
         }
-        ret.add(new Branch.NameKey(s.getProject(), dest));
+        ret.add(BranchNameKey.create(s.getProject(), dest));
       }
     }
 
     for (RefSpec r : s.getMultiMatchRefSpecs()) {
       logger.atFine().log("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.get())) {
+      if (!r.matchSource(src.branch())) {
         continue;
       }
       OpenRepo or;
@@ -344,7 +346,7 @@
         if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
           continue;
         }
-        Branch.NameKey b = new Branch.NameKey(s.getProject(), ref.getName());
+        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
         if (!ret.contains(b)) {
           ret.add(b);
         }
@@ -356,18 +358,18 @@
 
   @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
-      Branch.NameKey srcBranch) throws IOException {
+      BranchNameKey srcBranch) throws IOException {
     logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
-    Project.NameKey srcProject = srcBranch.getParentKey();
+    Project.NameKey srcProject = srcBranch.project();
     for (SubscribeSection s : projectCache.get(srcProject).getSubscribeSections(srcBranch)) {
       logger.atFine().log("Checking subscribe section %s", s);
-      Collection<Branch.NameKey> branches = getDestinationBranches(srcBranch, s);
-      for (Branch.NameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.getParentKey();
+      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
+      for (BranchNameKey targetBranch : branches) {
+        Project.NameKey targetProject = targetBranch.project();
         try {
           OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.get());
+          ObjectId id = or.repo.resolve(targetBranch.branch());
           if (id == null) {
             logger.atFine().log("The branch %s doesn't exist.", targetBranch);
             continue;
@@ -403,7 +405,7 @@
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (Branch.NameKey branch : branchesByProject.get(project)) {
+          for (BranchNameKey branch : branchesByProject.get(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
@@ -415,11 +417,11 @@
   }
 
   /** Create a separate gitlink commit */
-  private CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
+  private CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.getRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -428,7 +430,7 @@
     if (branchTips.containsKey(subscriber)) {
       currentCommit = branchTips.get(subscriber);
     } else {
-      Ref r = or.repo.exactRef(subscriber.get());
+      Ref r = or.repo.exactRef(subscriber.branch());
       if (r == null) {
         throw new SubmoduleException(
             "The branch was probably deleted from the subscriber repository");
@@ -485,11 +487,11 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.getRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -531,7 +533,7 @@
     logger.atFine().log("Updating gitlink for %s", s);
     OpenRepo subOr;
     try {
-      subOr = orm.getRepo(s.getSubmodule().getParentKey());
+      subOr = orm.getRepo(s.getSubmodule().project());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access submodule", e);
     }
@@ -544,7 +546,7 @@
             "Requested to update gitlink "
                 + s.getPath()
                 + " in "
-                + s.getSubmodule().getParentKey().get()
+                + s.getSubmodule().project().get()
                 + " but entry "
                 + "doesn't have gitlink file mode.";
         throw new SubmoduleException(errMsg);
@@ -576,7 +578,7 @@
       // superproject is still subscribed to this branch. Re-read the ref to see if anything has
       // changed since the last time the gitlink was updated, and roll that update into the same
       // commit as all other submodule updates.
-      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().get());
+      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().branch());
       if (ref == null) {
         ed.add(new DeletePath(s.getPath()));
         return null;
@@ -615,7 +617,7 @@
     msgbuf.append("* Update ");
     msgbuf.append(s.getPath());
     msgbuf.append(" from branch '");
-    msgbuf.append(s.getSubmodule().getShortName());
+    msgbuf.append(s.getSubmodule().shortName());
     msgbuf.append("'");
     msgbuf.append("\n  to ");
     msgbuf.append(newCommit.getName());
@@ -675,8 +677,8 @@
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
-    for (Branch.NameKey branch : updatedBranches) {
-      projects.add(branch.getParentKey());
+    for (BranchNameKey branch : updatedBranches) {
+      projects.add(branch.project());
     }
     return ImmutableSet.copyOf(projects);
   }
@@ -697,10 +699,10 @@
 
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
-    for (Branch.NameKey branch : branchesByProject.get(project)) {
+    for (BranchNameKey branch : branchesByProject.get(project)) {
       Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
       for (SubmoduleSubscription s : subscriptions) {
-        subprojects.add(s.getSubmodule().getParentKey());
+        subprojects.add(s.getSubmodule().project());
       }
     }
 
@@ -712,8 +714,8 @@
     projects.add(project);
   }
 
-  ImmutableSet<Branch.NameKey> getBranchesInOrder() {
-    LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
+  ImmutableSet<BranchNameKey> getBranchesInOrder() {
+    LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
     if (sortedBranches != null) {
       branches.addAll(sortedBranches);
     }
@@ -721,15 +723,15 @@
     return ImmutableSet.copyOf(branches);
   }
 
-  boolean hasSubscription(Branch.NameKey branch) {
+  boolean hasSubscription(BranchNameKey branch) {
     return targets.containsKey(branch);
   }
 
-  void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+  void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
     branchTips.put(branch, tip);
   }
 
-  void addOp(BatchUpdate bu, Branch.NameKey branch) {
+  void addOp(BatchUpdate bu, BranchNameKey branch) {
     bu.addRepoOnlyOp(new GitlinkOp(branch));
   }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 0ca9f5e..2a958af 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -52,6 +52,7 @@
 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.TooManyUpdatesException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -178,16 +179,9 @@
   }
 
   private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
-    Throwables.throwIfUnchecked(e);
-
-    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
-    // ResourceConflictException to indicate an atomic update failure.
-    Throwables.throwIfInstanceOf(e, UpdateException.class);
-    Throwables.throwIfInstanceOf(e, RestApiException.class);
-
-    // Convert other common non-REST exception types with user-visible messages to corresponding
-    // REST exception types
-    if (e instanceof InvalidChangeOperationException) {
+    // Convert common non-REST exception types with user-visible messages to corresponding REST
+    // exception types.
+    if (e instanceof InvalidChangeOperationException || e instanceof TooManyUpdatesException) {
       throw new ResourceConflictException(e.getMessage(), e);
     } else if (e instanceof NoSuchChangeException
         || e instanceof NoSuchRefException
@@ -195,6 +189,13 @@
       throw new ResourceNotFoundException(e.getMessage(), e);
     }
 
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
     // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
     throw new UpdateException(e);
   }
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index ae8ba53..3c14d25 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -28,7 +28,6 @@
 import com.github.rholder.retry.WaitStrategy;
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Predicate;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
@@ -38,7 +37,6 @@
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.Histogram1;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -48,6 +46,7 @@
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.function.Consumer;
+import java.util.function.Predicate;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -107,18 +106,18 @@
   @VisibleForTesting
   @Singleton
   public static class Metrics {
-    final Histogram1<ActionType> attemptCounts;
+    final Counter1<ActionType> attemptCounts;
     final Counter1<ActionType> timeoutCount;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
       Field<ActionType> view = Field.ofEnum(ActionType.class, "action_type");
       attemptCounts =
-          metricMaker.newHistogram(
-              "action/retry_attempt_counts",
+          metricMaker.newCounter(
+              "action/retry_attempt_count",
               new Description(
-                      "Distribution of number of attempts made by RetryHelper to execute an action"
-                          + " (1 == single attempt, no retry)")
+                      "Number of retry attempts made by RetryHelper to execute an action"
+                          + " (0 == single attempt, no retry)")
                   .setCumulative()
                   .setUnit("attempts"),
               view);
@@ -262,8 +261,8 @@
     } finally {
       if (listener.getAttemptCount() > 1) {
         logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+        metrics.attemptCounts.incrementBy(actionType, listener.getAttemptCount() - 1);
       }
-      metrics.attemptCounts.record(actionType, listener.getAttemptCount());
     }
   }
 
@@ -295,7 +294,7 @@
   private <O> RetryerBuilder<O> createRetryerBuilder(
       ActionType actionType, Options opts, Predicate<Throwable> exceptionPredicate) {
     RetryerBuilder<O> retryerBuilder =
-        RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate);
+        RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate::test);
     if (opts.listener() != null) {
       retryerBuilder.withRetryListener(opts.listener());
     }
diff --git a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
index f05d1d7..433a5f1 100644
--- a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
+++ b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.util.git;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import java.net.URI;
@@ -45,10 +45,10 @@
 
   private final Config config;
   private final String canonicalWebUrl;
-  private final Branch.NameKey superProjectBranch;
+  private final BranchNameKey superProjectBranch;
 
   public SubmoduleSectionParser(
-      Config config, String canonicalWebUrl, Branch.NameKey superProjectBranch) {
+      Config config, String canonicalWebUrl, BranchNameKey superProjectBranch) {
     this.config = config;
     this.canonicalWebUrl = canonicalWebUrl;
     this.superProjectBranch = superProjectBranch;
@@ -81,13 +81,13 @@
         String project;
 
         if (branch.equals(".")) {
-          branch = superProjectBranch.get();
+          branch = superProjectBranch.branch();
         }
 
         // relative URL
         if (url.startsWith("../")) {
           // prefix with a slash for easier relative path walks
-          project = '/' + superProjectBranch.getParentKey().get();
+          project = '/' + superProjectBranch.project().get();
           String hostPart = url;
           while (hostPart.startsWith("../")) {
             int lastSlash = project.lastIndexOf('/');
@@ -133,9 +133,9 @@
                   0, //
                   project.length() - Constants.DOT_GIT_EXT.length());
         }
-        Project.NameKey projectKey = new Project.NameKey(project);
+        Project.NameKey projectKey = Project.nameKey(project);
         return new SubmoduleSubscription(
-            superProjectBranch, new Branch.NameKey(projectKey, branch), path);
+            superProjectBranch, BranchNameKey.create(projectKey, branch), path);
       }
     } catch (URISyntaxException e) {
       // Error in url syntax (in fact it is uri syntax)
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 42ef7d5..3a69554 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 0b73bbf..dc838f2 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -122,7 +122,7 @@
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
     try {
-      return Arrays.asList(new Change.Id(Integer.parseInt(id)));
+      return Arrays.asList(Change.id(Integer.parseInt(id)));
     } catch (NumberFormatException e) {
       throw new UnloggedFailure(2, "Invalid change ID " + id, e);
     }
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index f3ba308..87923f6 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -62,7 +62,7 @@
       if (verboseOutput) {
         Optional<InternalGroup> group =
             info.ownerId != null
-                ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
+                ? groupCache.get(AccountGroup.uuid(Url.decode(info.ownerId)))
                 : Optional.empty();
 
         formatter.addColumn(Url.decode(info.id));
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 1565ecb..38feecf 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -69,7 +69,7 @@
     }
 
     void display(PrintWriter writer) throws PermissionBackendException {
-      Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(name));
+      Optional<InternalGroup> group = groupCache.get(AccountGroup.nameKey(name));
       String errorText = "Group not found or not visible\n";
 
       if (!group.isPresent()) {
diff --git a/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index a6e802a..377e1ac 100644
--- a/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.ObjectIds;
 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.RevId;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -56,7 +56,7 @@
       throws UnloggedFailure {
     // By commit?
     //
-    if (token.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
+    if (token.matches("^([0-9a-fA-F]{4," + ObjectIds.STR_LEN + "})$")) {
       InternalChangeQuery query = queryProvider.get();
       List<ChangeData> cds;
       if (projectState != null) {
@@ -76,7 +76,7 @@
           continue;
         }
         for (PatchSet ps : cd.patchSets()) {
-          if (ps.getRevision().matches(token)) {
+          if (ObjectIds.matchesAbbreviation(ps.commitId(), token)) {
             matches.add(ps);
           }
         }
@@ -101,7 +101,7 @@
       } catch (IllegalArgumentException e) {
         throw error("\"" + token + "\" is not a valid patch set");
       }
-      ChangeNotes notes = getNotes(projectState, patchSetId.getParentKey());
+      ChangeNotes notes = getNotes(projectState, patchSetId.changeId());
       PatchSet patchSet = psUtil.get(notes, patchSetId);
       if (patchSet == null) {
         throw error("\"" + token + "\" no such patch set");
@@ -147,7 +147,7 @@
       // No --branch option, so they want every branch.
       return true;
     }
-    return change.getDest().get().equals(branch);
+    return change.getDest().branch().equals(branch);
   }
 
   public static UnloggedFailure error(String msg) {
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index fa0e37b..d9fd5d3 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -19,6 +19,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.DefaultAdvertiseRefsHook;
@@ -141,7 +142,7 @@
         msg.append("  Visible references (").append(adv.size()).append("):\n");
         for (Ref ref : adv.values()) {
           msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
+              .append(abbreviateName(ref, rp))
               .append(" ")
               .append(ref.getName())
               .append("\n");
@@ -158,7 +159,7 @@
         msg.append("  Hidden references (").append(hidden.size()).append("):\n");
         for (Ref ref : hidden) {
           msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
+              .append(abbreviateName(ref, rp))
               .append(" ")
               .append(ref.getName())
               .append("\n");
@@ -169,4 +170,8 @@
       throw new Failure(128, "fatal: Unpack error, check server log", detail);
     }
   }
+
+  private String abbreviateName(Ref ref, ReceivePack rp) throws IOException {
+    return ObjectIds.abbreviateName(ref.getObjectId(), rp.getRevWalk().getObjectReader());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 7af07de..2c54e4a 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -227,11 +227,11 @@
         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.id().changeId().get());
       } catch (Exception e) {
         ok = false;
-        writeError("fatal", "internal server error while reviewing " + patchSet.getId() + "\n");
-        logger.atSevere().withCause(e).log("internal error while reviewing %s", patchSet.getId());
+        writeError("fatal", "internal server error while reviewing " + patchSet.id() + "\n");
+        logger.atSevere().withCause(e).log("internal error while reviewing %s", patchSet.id());
       }
     }
 
@@ -242,8 +242,8 @@
 
   private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
     gApi.changes()
-        .id(patchSet.getId().getParentKey().get())
-        .revision(patchSet.getRevision().get())
+        .id(patchSet.id().changeId().get())
+        .revision(patchSet.commitId().name())
         .review(review);
   }
 
@@ -310,11 +310,11 @@
   }
 
   private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
-    return gApi.changes().id(patchSet.getId().getParentKey().get());
+    return gApi.changes().id(patchSet.id().changeId().get());
   }
 
   private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
-    return changeApi(patchSet).revision(patchSet.getRevision().get());
+    return changeApi(patchSet).revision(patchSet.commitId().name());
   }
 
   @Override
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index dfdf7f2..466db4c 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -160,7 +160,7 @@
       automaticallyExcluded.addAll(getAllParents(newParentKey));
     }
     for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent, user))) {
-      final Project.NameKey childName = new Project.NameKey(child.name);
+      final Project.NameKey childName = Project.nameKey(child.name);
       if (!excluded.contains(childName)) {
         if (!automaticallyExcluded.contains(childName)) {
           childProjects.add(childName);
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index d579ef6..231bcf6 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -148,7 +148,7 @@
     }
 
     stdout.print("--\n");
-    stdout.print("SSHD Backend: " + getBackend() + "\n");
+    stdout.print(String.format(" %d connections; SSHD Backend: %s\n", list.size(), getBackend()));
   }
 
   private String getBackend() {
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index ffd98d5..447f7ec 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -16,26 +16,22 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Supplier;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventGson;
 import com.google.gerrit.server.events.EventTypes;
-import com.google.gerrit.server.events.ProjectNameKeySerializer;
-import com.google.gerrit.server.events.SupplierSerializer;
 import com.google.gerrit.server.events.UserScopedEventListener;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
 import com.google.gerrit.sshd.BaseCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.StreamCommandExecutor;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
@@ -49,7 +45,7 @@
 
 @RequiresCapability(GlobalCapability.STREAM_EVENTS)
 @CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
-final class StreamEvents extends BaseCommand {
+public final class StreamEvents extends BaseCommand {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   /** Maximum number of events that may be queued up for each connection. */
@@ -71,11 +67,11 @@
 
   @Inject @StreamCommandExecutor private ScheduledThreadPoolExecutor pool;
 
+  @Inject @EventGson private Gson gson;
+
   /** Queue of events to stream to the connected user. */
   private final LinkedBlockingQueue<Event> queue = new LinkedBlockingQueue<>(MAX_EVENTS);
 
-  private Gson gson;
-
   private RegistrationHandle eventListenerRegistration;
 
   /** Special event to notify clients they missed other events. */
@@ -165,12 +161,6 @@
                 return currentUser;
               }
             });
-
-    gson =
-        new GsonBuilder()
-            .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-            .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
-            .create();
   }
 
   private void removeEventListenerRegistration() {
diff --git a/java/com/google/gerrit/testing/AssertableExecutorService.java b/java/com/google/gerrit/testing/AssertableExecutorService.java
new file mode 100644
index 0000000..fabd7b78
--- /dev/null
+++ b/java/com/google/gerrit/testing/AssertableExecutorService.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.util.concurrent.ForwardingExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Forwards all calls to a direct executor making it so that the submitted {@link Runnable}s run
+ * synchronously. Holds a count of the number of tasks that were executed.
+ */
+public class AssertableExecutorService extends ForwardingExecutorService {
+
+  private final ExecutorService delegate = MoreExecutors.newDirectExecutorService();
+  private final AtomicInteger numInteractions = new AtomicInteger();
+
+  @Override
+  protected ExecutorService delegate() {
+    return delegate;
+  }
+
+  @Override
+  public <T> Future<T> submit(Callable<T> task) {
+    numInteractions.incrementAndGet();
+    return super.submit(task);
+  }
+
+  @Override
+  public Future<?> submit(Runnable task) {
+    numInteractions.incrementAndGet();
+    return super.submit(task);
+  }
+
+  @Override
+  public <T> Future<T> submit(Runnable task, T result) {
+    numInteractions.incrementAndGet();
+    return super.submit(task, result);
+  }
+
+  /** Asserts and resets the number of executions this executor observed. */
+  public void assertInteractions(int expectedNumInteractions) {
+    assertThat(numInteractions.get())
+        .named("expectedRunnablesSubmittedOnExecutor")
+        .isEqualTo(expectedNumInteractions);
+    numInteractions.set(0);
+  }
+}
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index ec5076e..f896eaa 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -1,7 +1,10 @@
 java_library(
     name = "gerrit-test-util",
     testonly = True,
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["AssertableExecutorService.java"],
+    ),
     visibility = ["//visibility:public"],
     exports = [
         "//lib/easymock",
@@ -47,3 +50,15 @@
         "//lib/truth",
     ],
 )
+
+java_library(
+    # This can't be part of gerrit-test-util because of https://github.com/google/guava/issues/2837
+    name = "assertable-executor",
+    testonly = True,
+    srcs = ["AssertableExecutorService.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/testing/GerritJUnit.java b/java/com/google/gerrit/testing/GerritJUnit.java
new file mode 100644
index 0000000..0771c39
--- /dev/null
+++ b/java/com/google/gerrit/testing/GerritJUnit.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+/** Static JUnit utility methods. */
+public class GerritJUnit {
+  /**
+   * Assert that an exception is thrown by a block of code.
+   *
+   * <p>This method is source-compatible with <a
+   * href="https://junit.org/junit4/javadoc/latest/org/junit/Assert.html#assertThrows(java.lang.Class,%20org.junit.function.ThrowingRunnable)">JUnit
+   * 4.13 beta</a>.
+   *
+   * <p>This construction is recommended by the Truth team for use in conjunction with asserting
+   * over a {@code ThrowableSubject} on the return type:
+   *
+   * <pre>
+   *   MyException e = assertThrows(MyException.class, () -> doSomething(foo));
+   *   assertThat(e).isInstanceOf(MySubException.class);
+   *   assertThat(e).hasMessageThat().contains("sub-exception occurred");
+   * </pre>
+   *
+   * @param throwableClass expected exception type.
+   * @param runnable runnable containing arbitrary code.
+   * @return exception that was thrown.
+   */
+  public static <T extends Throwable> T assertThrows(
+      Class<T> throwableClass, ThrowingRunnable runnable) {
+    try {
+      runnable.run();
+    } catch (Throwable t) {
+      if (!throwableClass.isInstance(t)) {
+        throw new AssertionError(
+            "expected "
+                + throwableClass.getName()
+                + " but "
+                + t.getClass().getName()
+                + " was thrown",
+            t);
+      }
+      @SuppressWarnings("unchecked")
+      T toReturn = (T) t;
+      return toReturn;
+    }
+    throw new AssertionError(
+        "expected " + throwableClass.getName() + " but no exception was thrown");
+  }
+
+  @FunctionalInterface
+  public interface ThrowingRunnable {
+    void run() throws Throwable;
+  }
+
+  private GerritJUnit() {}
+}
diff --git a/java/com/google/gerrit/testing/GerritServerTests.java b/java/com/google/gerrit/testing/GerritServerTests.java
index 9a922d6..ad985b6 100644
--- a/java/com/google/gerrit/testing/GerritServerTests.java
+++ b/java/com/google/gerrit/testing/GerritServerTests.java
@@ -21,7 +21,7 @@
 import org.junit.runners.model.Statement;
 
 @RunWith(ConfigSuite.class)
-public class GerritServerTests extends GerritBaseTests {
+public class GerritServerTests {
   @ConfigSuite.Parameter public Config config;
 
   @ConfigSuite.Name private String configName;
diff --git a/java/com/google/gerrit/testing/GerritBaseTests.java b/java/com/google/gerrit/testing/GerritTestName.java
similarity index 65%
rename from java/com/google/gerrit/testing/GerritBaseTests.java
rename to java/com/google/gerrit/testing/GerritTestName.java
index d6a2261..d003289 100644
--- a/java/com/google/gerrit/testing/GerritBaseTests.java
+++ b/java/com/google/gerrit/testing/GerritTestName.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -15,18 +15,16 @@
 package com.google.gerrit.testing;
 
 import com.google.common.base.CharMatcher;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
 import org.junit.rules.TestName;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
 
-@Ignore
-public abstract class GerritBaseTests {
-  @Rule public ExpectedException exception = ExpectedException.none();
-  @Rule public final TestName testName = new TestName();
+public class GerritTestName implements TestRule {
+  private final TestName delegate = new TestName();
 
-  protected String getSanitizedMethodName() {
-    String name = testName.getMethodName().toLowerCase();
+  public String getSanitizedMethodName() {
+    String name = delegate.getMethodName().toLowerCase();
     name =
         CharMatcher.inRange('a', 'z')
             .or(CharMatcher.inRange('A', 'Z'))
@@ -36,4 +34,9 @@
     name = CharMatcher.is('_').trimTrailingFrom(name);
     return name;
   }
+
+  @Override
+  public Statement apply(Statement base, Description description) {
+    return delegate.apply(base, description);
+  }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index c8cea6f..98ac13b 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -174,8 +174,8 @@
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
-    // TODO(dborowitz): Use Jimfs. The biggest blocker is that JGit does not support Path-based
-    // Configs, only FileBasedConfig.
+    // It would be nice to use Jimfs for the SitePath, but the biggest blocker is that JGit does not
+    // support Path-based Configs, only FileBasedConfig.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
     bind(GerritOptions.class).toInstance(new GerritOptions(false, false, false));
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index e44d8d38..fd9818a 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -103,7 +103,7 @@
   public synchronized SortedSet<Project.NameKey> list() {
     SortedSet<Project.NameKey> names = Sets.newTreeSet();
     for (DfsRepository repo : repos.values()) {
-      names.add(new Project.NameKey(repo.getDescription().getRepositoryName()));
+      names.add(Project.nameKey(repo.getDescription().getRepositoryName()));
     }
     return ImmutableSortedSet.copyOf(names);
   }
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index fd097d3..0ec03b8 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -19,12 +19,11 @@
 import com.google.common.collect.Ordering;
 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.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 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.reviewdb.client.RevId;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
@@ -52,13 +51,13 @@
   }
 
   public static Change newChange(Project.NameKey project, Account.Id userId, int id) {
-    Change.Id changeId = new Change.Id(id);
+    Change.Id changeId = Change.id(id);
     Change c =
         new Change(
-            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+            Change.key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
             changeId,
             userId,
-            new Branch.NameKey(project, "master"),
+            BranchNameKey.create(project, "master"),
             TimeUtil.nowTs());
     incrementPatchSet(c);
     return c;
@@ -69,11 +68,12 @@
   }
 
   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);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    return ps;
+    return PatchSet.builder()
+        .id(id)
+        .commitId(ObjectId.fromString(revision))
+        .uploader(userId)
+        .createdOn(TimeUtil.nowTs())
+        .build();
   }
 
   public static ChangeUpdate newUpdate(
@@ -115,11 +115,11 @@
               .author(ident)
               .committer(ident)
               .message(firstNonNull(c.getSubject(), "Test change"));
-      Ref parent = repo.exactRef(c.getDest().get());
+      Ref parent = repo.exactRef(c.getDest().branch());
       if (parent != null) {
         cb.parent(tr.getRevWalk().parseCommit(parent.getObjectId()));
       }
-      update.setBranch(c.getDest().get());
+      update.setBranch(c.getDest().branch());
       update.setChangeId(c.getKey().get());
       update.setCommit(tr.getRevWalk(), cb.create());
       return update;
@@ -129,7 +129,7 @@
   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));
+        new PatchSetInfo(PatchSet.id(change.getId(), curr != null ? curr.get() + 1 : 1));
     ps.setSubject("Change subject");
     change.setCurrentPatchSet(ps);
   }
diff --git a/java/com/google/gerrit/truth/BUILD b/java/com/google/gerrit/truth/BUILD
index 6f958b1..4727da1 100644
--- a/java/com/google/gerrit/truth/BUILD
+++ b/java/com/google/gerrit/truth/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/truth/CacheStatsSubject.java b/java/com/google/gerrit/truth/CacheStatsSubject.java
index f1a9393..22c33c2 100644
--- a/java/com/google/gerrit/truth/CacheStatsSubject.java
+++ b/java/com/google/gerrit/truth/CacheStatsSubject.java
@@ -39,10 +39,12 @@
         other.evictionCount());
   }
 
+  private final CacheStats stats;
   private CacheStats start = new CacheStats(0, 0, 0, 0, 0, 0);
 
   private CacheStatsSubject(FailureMetadata failureMetadata, CacheStats stats) {
     super(failureMetadata, stats);
+    this.stats = stats;
   }
 
   public CacheStatsSubject since(CacheStats start) {
@@ -52,11 +54,11 @@
 
   public void hasHitCount(int expectedHitCount) {
     isNotNull();
-    check("hitCount()").that(actual().minus(start).hitCount()).isEqualTo(expectedHitCount);
+    check("hitCount()").that(stats.minus(start).hitCount()).isEqualTo(expectedHitCount);
   }
 
   public void hasMissCount(int expectedMissCount) {
     isNotNull();
-    check("missCount()").that(actual().minus(start).missCount()).isEqualTo(expectedMissCount);
+    check("missCount()").that(stats.minus(start).missCount()).isEqualTo(expectedMissCount);
   }
 }
diff --git a/java/com/google/gerrit/truth/ConfigSubject.java b/java/com/google/gerrit/truth/ConfigSubject.java
new file mode 100644
index 0000000..2a99151
--- /dev/null
+++ b/java/com/google/gerrit/truth/ConfigSubject.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.truth.BooleanSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.ListMultimapSubject;
+import com.google.common.truth.LongSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.common.Nullable;
+import java.util.Arrays;
+import org.eclipse.jgit.lib.Config;
+
+public class ConfigSubject extends Subject<ConfigSubject, Config> {
+  public static ConfigSubject assertThat(Config config) {
+    return assertAbout(ConfigSubject::new).that(config);
+  }
+
+  private final Config config;
+
+  private ConfigSubject(FailureMetadata metadata, Config actual) {
+    super(metadata, actual);
+    this.config = actual;
+  }
+
+  public IterableSubject sections() {
+    isNotNull();
+    return check("getSections()").that(config.getSections());
+  }
+
+  public IterableSubject subsections(String section) {
+    requireNonNull(section);
+    isNotNull();
+    return check("getSubsections(%s)", section).that(config.getSubsections(section));
+  }
+
+  public ListMultimapSubject sectionValues(String section) {
+    requireNonNull(section);
+    return sectionValuesImpl(section, null);
+  }
+
+  public ListMultimapSubject subsectionValues(String section, String subsection) {
+    requireNonNull(section);
+    requireNonNull(subsection);
+    return sectionValuesImpl(section, subsection);
+  }
+
+  private ListMultimapSubject sectionValuesImpl(String section, @Nullable String subsection) {
+    isNotNull();
+    ImmutableListMultimap.Builder<String, String> b = ImmutableListMultimap.builder();
+    config
+        .getNames(section, subsection, true)
+        .forEach(
+            n ->
+                Arrays.stream(config.getStringList(section, subsection, n))
+                    .forEach(v -> b.put(n, v)));
+    return check("getSection(%s, %s)", section, subsection).that(b.build());
+  }
+
+  public void isEmpty() {
+    sections().isEmpty();
+  }
+
+  public StringSubject text() {
+    isNotNull();
+    return check("toText()").that(config.toText());
+  }
+
+  public IterableSubject stringValues(String section, @Nullable String subsection, String name) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getStringList(%s, %s, %s)", section, subsection, name)
+        .that(Arrays.asList(config.getStringList(section, subsection, name)));
+  }
+
+  public StringSubject stringValue(String section, @Nullable String subsection, String name) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getString(%s, %s, %s)", section, subsection, name)
+        .that(config.getString(section, subsection, name));
+  }
+
+  public IntegerSubject intValue(
+      String section, @Nullable String subsection, String name, int defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getInt(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getInt(section, subsection, name, defaultValue));
+  }
+
+  public LongSubject longValue(String section, String subsection, String name, long defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getLong(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getLong(section, subsection, name, defaultValue));
+  }
+
+  public BooleanSubject booleanValue(
+      String section, String subsection, String name, boolean defaultValue) {
+    requireNonNull(section);
+    requireNonNull(name);
+    isNotNull();
+    return check("getBoolean(%s, %s, %s, %s)", section, subsection, name, defaultValue)
+        .that(config.getBoolean(section, subsection, name, defaultValue));
+  }
+}
diff --git a/java/com/google/gerrit/truth/ListSubject.java b/java/com/google/gerrit/truth/ListSubject.java
index 9a839dd..0da16f8 100644
--- a/java/com/google/gerrit/truth/ListSubject.java
+++ b/java/com/google/gerrit/truth/ListSubject.java
@@ -29,6 +29,7 @@
 
 public class ListSubject<S extends Subject<S, E>, E> extends IterableSubject {
 
+  private final List<E> list;
   private final BiFunction<StandardSubjectBuilder, E, S> elementSubjectCreator;
 
   public static <S extends Subject<S, E>, E> ListSubject<S, E> assertThat(
@@ -45,13 +46,13 @@
       List<E> list,
       BiFunction<StandardSubjectBuilder, E, S> elementSubjectCreator) {
     super(failureMetadata, list);
+    this.list = list;
     this.elementSubjectCreator = elementSubjectCreator;
   }
 
   public S element(int index) {
     checkArgument(index >= 0, "index(%s) must be >= 0", index);
     isNotNull();
-    List<E> list = getActualList();
     if (index >= list.size()) {
       failWithoutActual(fact("expected to have element at index", index));
     }
@@ -61,30 +62,15 @@
   public S onlyElement() {
     isNotNull();
     hasSize(1);
-    List<E> list = getActualList();
     return elementSubjectCreator.apply(check("onlyElement()"), Iterables.getOnlyElement(list));
   }
 
   public S lastElement() {
     isNotNull();
     isNotEmpty();
-    List<E> list = getActualList();
     return elementSubjectCreator.apply(check("lastElement()"), Iterables.getLast(list));
   }
 
-  @SuppressWarnings("unchecked")
-  private List<E> getActualList() {
-    // The constructor only accepts lists. -> Casting is appropriate.
-    return (List<E>) actual();
-  }
-
-  @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);
-  }
-
   public static class ListSubjectBuilder extends CustomSubjectBuilder {
 
     ListSubjectBuilder(FailureMetadata failureMetadata) {
diff --git a/java/com/google/gerrit/truth/OptionalSubject.java b/java/com/google/gerrit/truth/OptionalSubject.java
index b5fc5d0..dd1e419 100644
--- a/java/com/google/gerrit/truth/OptionalSubject.java
+++ b/java/com/google/gerrit/truth/OptionalSubject.java
@@ -29,6 +29,7 @@
 public class OptionalSubject<S extends Subject<S, ? super T>, T>
     extends Subject<OptionalSubject<S, T>, Optional<T>> {
 
+  private final Optional<T> optional;
   private final BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator;
 
   // TODO(aliceks): Remove when all relevant usages are adapted to new check()/factory approach.
@@ -62,12 +63,12 @@
       Optional<T> optional,
       BiFunction<StandardSubjectBuilder, ? super T, ? extends S> valueSubjectCreator) {
     super(failureMetadata, optional);
+    this.optional = optional;
     this.valueSubjectCreator = valueSubjectCreator;
   }
 
   public void isPresent() {
     isNotNull();
-    Optional<T> optional = actual();
     if (!optional.isPresent()) {
       failWithoutActual(fact("expected to have", "value"));
     }
@@ -75,7 +76,6 @@
 
   public void isAbsent() {
     isNotNull();
-    Optional<T> optional = actual();
     if (optional.isPresent()) {
       failWithoutActual(fact("expected not to have", "value"));
     }
@@ -88,7 +88,6 @@
   public S value() {
     isNotNull();
     isPresent();
-    Optional<T> optional = actual();
     return valueSubjectCreator.apply(check("value()"), optional.get());
   }
 
diff --git a/java/com/google/gwtorm/BUILD b/java/com/google/gwtorm/BUILD
deleted file mode 100644
index e9cac34..0000000
--- a/java/com/google/gwtorm/BUILD
+++ /dev/null
@@ -1,5 +0,0 @@
-java_library(
-    name = "gwtorm",
-    srcs = glob(["**/*.java"]),
-    visibility = ["//visibility:public"],
-)
diff --git a/java/com/google/gwtorm/client/CompoundKey.java b/java/com/google/gwtorm/client/CompoundKey.java
deleted file mode 100644
index 1c66d18..0000000
--- a/java/com/google/gwtorm/client/CompoundKey.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtorm.client;
-
-import java.io.Serializable;
-
-/**
- * Abstract key type composed of other keys.
- *
- * <p>Applications should subclass this type to create their own entity-specific key classes.
- *
- * @param <P> the parent key type. Use {@link Key} if no parent key is needed.
- */
-@SuppressWarnings("serial")
-public abstract class CompoundKey<P extends Key<?>> implements Key<P>, Serializable {
-  /** @return the member key components, minus the parent key. */
-  public abstract Key<?>[] members();
-
-  /** @return the parent key instance; null if this is a root level key. */
-  @Override
-  public P getParentKey() {
-    return null;
-  }
-
-  @Override
-  public int hashCode() {
-    int hc = 0;
-    if (getParentKey() != null) {
-      hc = getParentKey().hashCode();
-    }
-    for (final Key<?> k : members()) {
-      hc *= 31;
-      hc += k.hashCode();
-    }
-    return hc;
-  }
-
-  @Override
-  public boolean equals(final Object b) {
-    if (b == null || b.getClass() != getClass()) {
-      return false;
-    }
-
-    final CompoundKey<P> q = cast(b);
-    if (getParentKey() != null && !getParentKey().equals(q.getParentKey())) {
-      return false;
-    }
-
-    final Key<?>[] aMembers = members();
-    final Key<?>[] bMembers = q.members();
-    if (aMembers.length != bMembers.length) {
-      return false;
-    }
-    for (int i = 0; i < aMembers.length; i++) {
-      if (!aMembers[i].equals(bMembers[i])) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  @Override
-  public String toString() {
-    final StringBuffer r = new StringBuffer();
-    boolean first = true;
-    if (getParentKey() != null) {
-      r.append(KeyUtil.encode(getParentKey().toString()));
-      first = false;
-    }
-    for (final Key<?> k : members()) {
-      if (!first) {
-        r.append(',');
-      }
-      r.append(KeyUtil.encode(k.toString()));
-      first = false;
-    }
-    return r.toString();
-  }
-
-  @Override
-  public void fromString(final String in) {
-    final String[] parts = in.split(",");
-    int p = 0;
-    if (getParentKey() != null) {
-      getParentKey().fromString(KeyUtil.decode(parts[p++]));
-    }
-    for (final Key<?> k : members()) {
-      k.fromString(KeyUtil.decode(parts[p++]));
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <A extends Key<?>> CompoundKey<A> cast(final Object b) {
-    return (CompoundKey<A>) b;
-  }
-}
diff --git a/java/com/google/gwtorm/client/IntKey.java b/java/com/google/gwtorm/client/IntKey.java
deleted file mode 100644
index 08c90e0..0000000
--- a/java/com/google/gwtorm/client/IntKey.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtorm.client;
-
-import java.io.Serializable;
-
-/**
- * Abstract key type using a single integer value.
- *
- * <p>Applications should subclass this type to create their own entity-specific key classes.
- *
- * @param <P> the parent key type. Use {@link Key} if no parent key is needed.
- */
-@SuppressWarnings("serial")
-public abstract class IntKey<P extends Key<?>> implements Key<P>, Serializable {
-  /** @return id of the entity instance. */
-  public abstract int get();
-
-  /** @param newValue the new value of this key. */
-  protected abstract void set(int newValue);
-
-  /** @return the parent key instance; null if this is a root level key. */
-  @Override
-  public P getParentKey() {
-    return null;
-  }
-
-  @Override
-  public int hashCode() {
-    int hc = get();
-    if (getParentKey() != null) {
-      hc *= 31;
-      hc += getParentKey().hashCode();
-    }
-    return hc;
-  }
-
-  @Override
-  public boolean equals(final Object b) {
-    if (b == null || b.getClass() != getClass()) {
-      return false;
-    }
-
-    final IntKey<P> q = cast(b);
-    return get() == q.get() && KeyUtil.eq(getParentKey(), q.getParentKey());
-  }
-
-  @Override
-  public String toString() {
-    final StringBuffer r = new StringBuffer();
-    if (getParentKey() != null) {
-      r.append(getParentKey().toString());
-      r.append(',');
-    }
-    r.append(get());
-    return r.toString();
-  }
-
-  @Override
-  public void fromString(final String in) {
-    set(Integer.parseInt(KeyUtil.parseFromString(getParentKey(), in)));
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <A extends Key<?>> IntKey<A> cast(final Object b) {
-    return (IntKey<A>) b;
-  }
-}
diff --git a/java/com/google/gwtorm/client/Key.java b/java/com/google/gwtorm/client/Key.java
deleted file mode 100644
index 69a2248..0000000
--- a/java/com/google/gwtorm/client/Key.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtorm.client;
-
-/**
- * Generic type for an entity key.
- *
- * <p>Although not required, entities should make their primary key type implement this interface,
- * permitting traversal up through the containment hierarchy of the entity keys.
- *
- * @param <P> type of the parent key. If no parent, use {@link Key} itself.
- */
-public interface Key<P extends Key<?>> {
-  /**
-   * Get the parent key instance.
-   *
-   * @return the parent key; null if this entity key is a root-level key.
-   */
-  public P getParentKey();
-
-  @Override
-  public int hashCode();
-
-  @Override
-  public boolean equals(Object o);
-
-  /** @return the key, encoded in a string format . */
-  @Override
-  public String toString();
-
-  /** Reset this key instance to represent the data in the supplied string. */
-  public void fromString(String in);
-}
diff --git a/java/com/google/gwtorm/client/KeyUtil.java b/java/com/google/gwtorm/client/KeyUtil.java
deleted file mode 100644
index e236d37..0000000
--- a/java/com/google/gwtorm/client/KeyUtil.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtorm.client;
-
-/** Common utility functions for {@link Key} implementors. */
-public class KeyUtil {
-  private static Encoder ENCODER_IMPL = new StandardKeyEncoder();
-
-  /**
-   * Determine if two keys are equal, supporting null references.
-   *
-   * @param <T> type of the key entity.
-   * @param a first key to test; may be null.
-   * @param b second key to test; may be null.
-   * @return true if both <code>a</code> and <code>b</code> are null, or if both are not-null and
-   *     <code>a.equals(b)</code> is true. Otherwise false.
-   */
-  public static <T extends Key<?>> boolean eq(final T a, final T b) {
-    if (a == b) {
-      return true;
-    }
-    if (a == null || b == null) {
-      return false;
-    }
-    return a.equals(b);
-  }
-
-  /**
-   * Encode a string to be safe for use within a URL like string.
-   *
-   * <p>The returned encoded string has URL component characters escaped with hex escapes (e.g. ' '
-   * is '+' and '%' is '%25'). The special character '/' is left literal. The comma character (',')
-   * is always encoded, permitting multiple encoded string values to be joined together safely.
-   *
-   * @param e the string to encode, must not be null.
-   * @return the encoded string.
-   */
-  public static String encode(final String e) {
-    return ENCODER_IMPL.encode(e);
-  }
-
-  /**
-   * Decode a string previously encoded by {@link #encode(String)}.
-   *
-   * @param e the string to decode, must not be null.
-   * @return the decoded string.
-   */
-  public static String decode(final String e) {
-    return ENCODER_IMPL.decode(e);
-  }
-
-  /**
-   * Split a string along the last comma and parse into the parent.
-   *
-   * @param parent parent key; <code>parent.fromString(in[0..comma])</code>.
-   * @param in the input string.
-   * @return text (if any) after the last comma in the input.
-   */
-  public static String parseFromString(final Key<?> parent, final String in) {
-    final int comma = in.lastIndexOf(',');
-    if (comma < 0 && parent == null) {
-      return decode(in);
-    }
-    if (comma < 0 && parent != null) {
-      throw new IllegalArgumentException("Not enough components: " + in);
-    }
-    assert (parent != null);
-    parent.fromString(in.substring(0, comma));
-    return decode(in.substring(comma + 1));
-  }
-
-  public abstract static class Encoder {
-    public abstract String encode(String e);
-
-    public abstract String decode(String e);
-  }
-
-  private KeyUtil() {}
-}
diff --git a/java/com/google/gwtorm/client/StringKey.java b/java/com/google/gwtorm/client/StringKey.java
deleted file mode 100644
index e56661f..0000000
--- a/java/com/google/gwtorm/client/StringKey.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright 2008 Google Inc.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gwtorm.client;
-
-import java.io.Serializable;
-
-/**
- * Abstract key type using a single string value.
- *
- * <p>Applications should subclass this type to create their own entity-specific key classes.
- *
- * @param <P> the parent key type. Use {@link Key} if no parent key is needed.
- */
-@SuppressWarnings("serial")
-public abstract class StringKey<P extends Key<?>>
-    implements Key<P>, Serializable, Comparable<StringKey<?>> {
-  /** @return name of the entity instance. */
-  public abstract String get();
-
-  /** @param newValue the new value of this key. */
-  protected abstract void set(String newValue);
-
-  /** @return the parent key instance; null if this is a root level key. */
-  @Override
-  public P getParentKey() {
-    return null;
-  }
-
-  @Override
-  public int hashCode() {
-    int hc = get() != null ? get().hashCode() : 0;
-    if (getParentKey() != null) {
-      hc *= 31;
-      hc += getParentKey().hashCode();
-    }
-    return hc;
-  }
-
-  @Override
-  public boolean equals(final Object b) {
-    if (b == null || get() == null || b.getClass() != getClass()) {
-      return false;
-    }
-
-    final StringKey<P> q = cast(b);
-    return get().equals(q.get()) && KeyUtil.eq(getParentKey(), q.getParentKey());
-  }
-
-  @Override
-  public int compareTo(final StringKey<?> other) {
-    return get().compareTo(other.get());
-  }
-
-  @Override
-  public String toString() {
-    final StringBuffer r = new StringBuffer();
-    if (getParentKey() != null) {
-      r.append(getParentKey().toString());
-      r.append(',');
-    }
-    r.append(KeyUtil.encode(get()));
-    return r.toString();
-  }
-
-  @Override
-  public void fromString(final String in) {
-    set(KeyUtil.parseFromString(getParentKey(), in));
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <A extends Key<?>> StringKey<A> cast(final Object b) {
-    return (StringKey<A>) b;
-  }
-}
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 693c89e..d491c0e 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -38,16 +38,15 @@
     LabelTypes types = cd.getLabelTypes();
 
     for (PatchSetApproval a : cd.currentApprovals()) {
-      LabelType t = types.byLabel(a.getLabelId());
+      LabelType t = types.byLabel(a.labelId());
       if (t == null) {
         continue;
       }
 
       StructureTerm labelTerm =
-          new StructureTerm(
-              sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.getValue()));
+          new StructureTerm(sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.value()));
 
-      StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.getAccountId().get()));
+      StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.accountId().get()));
 
       listHead = new ListTerm(new StructureTerm(sym_commit_label, labelTerm, userTerm), listHead);
     }
diff --git a/java/gerrit/PRED_change_branch_1.java b/java/gerrit/PRED_change_branch_1.java
index 0a7bb74..aef00f2 100644
--- a/java/gerrit/PRED_change_branch_1.java
+++ b/java/gerrit/PRED_change_branch_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -34,9 +34,9 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Branch.NameKey name = StoredValues.getChange(engine).getDest();
+    BranchNameKey name = StoredValues.getChange(engine).getDest();
 
-    if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
+    if (!a1.unify(SymbolTerm.create(name.branch()), engine.trail)) {
       return engine.fail();
     }
     return cont;
diff --git a/java/gerrit/PRED_uploader_1.java b/java/gerrit/PRED_uploader_1.java
index 029b84a..feb8302 100644
--- a/java/gerrit/PRED_uploader_1.java
+++ b/java/gerrit/PRED_uploader_1.java
@@ -50,7 +50,7 @@
       return engine.fail();
     }
 
-    Account.Id uploaderId = patchSet.getUploader();
+    Account.Id uploaderId = patchSet.uploader();
 
     if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())), engine.trail)) {
       return engine.fail();
diff --git a/javatests/com/google/gerrit/acceptance/BUILD b/javatests/com/google/gerrit/acceptance/BUILD
index 54b3626..405610b 100644
--- a/javatests/com/google/gerrit/acceptance/BUILD
+++ b/javatests/com/google/gerrit/acceptance/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
         "//lib:guava",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java b/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
index 69fdc7e..3d17de0 100644
--- a/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
+++ b/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.File;
 import java.nio.file.Files;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.FS;
 import org.junit.Test;
 
-public class MergeableFileBasedConfigTest extends GerritBaseTests {
+public class MergeableFileBasedConfigTest {
   @Test
   public void mergeNull() throws Exception {
     MergeableFileBasedConfig cfg = newConfig();
@@ -112,7 +112,7 @@
   }
 
   private void assertConfig(MergeableFileBasedConfig cfg, String expected) throws Exception {
-    assertThat(cfg.toText()).isEqualTo(expected);
+    assertThat(cfg).text().isEqualTo(expected);
     cfg.save();
     assertThat(new String(Files.readAllBytes(cfg.getFile().toPath()), UTF_8)).isEqualTo(expected);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 53d8ef8..2b30fe9 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
@@ -50,7 +49,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ProjectResetterTest extends GerritBaseTests {
+public class ProjectResetterTest {
   private InMemoryRepositoryManager repoManager;
   private Project.NameKey project;
   private Repository repo;
@@ -58,7 +57,7 @@
   @Before
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
-    project = new Project.NameKey("foo");
+    project = Project.nameKey("foo");
     repo = repoManager.createRepository(project);
   }
 
@@ -135,7 +134,7 @@
 
   @Test
   public void onlyResetMatchingRefsMultipleProjects() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     Ref matchingRefProject1 = createRef("refs/foo/test");
@@ -170,7 +169,7 @@
 
   @Test
   public void onlyDeleteNewlyCreatedMatchingRefsMultipleProjects() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     Ref matchingRefProject1;
@@ -216,7 +215,7 @@
 
   @Test
   public void projectEvictionIfRefsMetaConfigIsReset() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
     Ref metaConfig = createRef(repo2, RefNames.REFS_CONFIG);
 
@@ -239,7 +238,7 @@
 
   @Test
   public void projectEvictionIfRefsMetaConfigIsDeleted() throws Exception {
-    Project.NameKey project2 = new Project.NameKey("bar");
+    Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
     ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
@@ -259,8 +258,8 @@
 
   @Test
   public void accountEvictionIfUserBranchIsReset() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     Ref userBranch = createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
@@ -275,7 +274,7 @@
     EasyMock.replay(accountIndexer);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(2)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(2)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -289,8 +288,8 @@
 
   @Test
   public void accountEvictionIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
     AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
@@ -307,7 +306,7 @@
         builder(null, accountCache, accountIndexer, null, null, null, null)
             .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
       // Non-user branch because it's not in All-Users.
-      createRef(RefNames.refsUsers(new Account.Id(2)));
+      createRef(RefNames.refsUsers(Account.id(2)));
 
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
@@ -317,13 +316,13 @@
 
   @Test
   public void accountEvictionIfExternalIdsBranchIsReset() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     Ref externalIds = createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
     createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    Account.Id accountId2 = new Account.Id(2);
+    Account.Id accountId2 = Account.id(2);
 
     AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
     accountCache.evict(accountId);
@@ -340,7 +339,7 @@
     EasyMock.replay(accountIndexer);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -355,12 +354,12 @@
 
   @Test
   public void accountEvictionIfExternalIdsBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    Account.Id accountId2 = new Account.Id(2);
+    Account.Id accountId2 = Account.id(2);
 
     AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
     accountCache.evict(accountId);
@@ -377,7 +376,7 @@
     EasyMock.replay(accountIndexer);
 
     // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -392,8 +391,8 @@
 
   @Test
   public void accountEvictionFromAccountCreatorIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = new Account.Id(1);
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Account.Id accountId = Account.id(1);
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
     AccountCreator accountCreator = EasyMock.createNiceMock(AccountCreator.class);
@@ -412,10 +411,10 @@
 
   @Test
   public void groupEviction() throws Exception {
-    AccountGroup.UUID uuid1 = new AccountGroup.UUID("abcd1");
-    AccountGroup.UUID uuid2 = new AccountGroup.UUID("abcd2");
-    AccountGroup.UUID uuid3 = new AccountGroup.UUID("abcd3");
-    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("abcd1");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("abcd2");
+    AccountGroup.UUID uuid3 = AccountGroup.uuid("abcd3");
+    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
     GroupCache cache = EasyMock.createNiceMock(GroupCache.class);
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
index bf387fd..fc42474 100644
--- a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -30,7 +30,7 @@
   @Inject private UniversalGroupBackend universalGroupBackend;
 
   private final TestGroupBackend testGroupBackend = new TestGroupBackend();
-  private final AccountGroup.UUID testUUID = new AccountGroup.UUID("testbackend:test");
+  private final AccountGroup.UUID testUUID = AccountGroup.uuid("testbackend:test");
 
   @Test
   public void handlesTestGroup() throws Exception {
@@ -49,7 +49,7 @@
 
   @Test
   public void doesNotHandleLDAP() throws Exception {
-    assertThat(testGroupBackend.handles(new AccountGroup.UUID("ldap:1234"))).isFalse();
+    assertThat(testGroupBackend.handles(AccountGroup.uuid("ldap:1234"))).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 4a08eb8..fc04204 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -32,6 +32,8 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -47,7 +49,6 @@
 import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -105,7 +106,7 @@
 import com.google.gerrit.mail.Address;
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -342,7 +343,7 @@
     assertThat(accountInfo.email).isEqualTo(input.email);
     assertThat(accountInfo.status).isNull();
 
-    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    Account.Id accountId = Account.id(accountInfo._accountId);
     accountIndexedCounter.assertReindexOf(accountId, 1);
     assertThat(externalIds.byAccount(accountId))
         .containsExactly(
@@ -355,9 +356,11 @@
     AccountInput input = new AccountInput();
     input.username = admin.username();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("username '" + admin.username() + "' already exists");
-    gApi.accounts().create(input);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.accounts().create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("username '" + admin.username() + "' already exists");
   }
 
   @Test
@@ -366,15 +369,15 @@
     input.username = "foo";
     input.email = admin.email();
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("email '" + admin.email() + "' already exists");
-    gApi.accounts().create(input);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.accounts().create(input));
+    assertThat(thrown).hasMessageThat().contains("email '" + admin.email() + "' already exists");
   }
 
   @Test
   public void commitMessageOnAccountUpdates() throws Exception {
     AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     au.insert("Create Test Account", accountId, u -> {});
     assertLastCommitMessageOfUserBranch(accountId, "Create Test Account");
 
@@ -396,7 +399,7 @@
   public void createAtomically() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
     try {
-      Account.Id accountId = new Account.Id(seq.nextAccountId());
+      Account.Id accountId = Account.id(seq.nextAccountId());
       String fullName = "Foo";
       ExternalId extId = ExternalId.createEmail(accountId, "foo@example.com");
       AccountState accountState =
@@ -425,7 +428,7 @@
 
   @Test
   public void updateNonExistingAccount() throws Exception {
-    Account.Id nonExistingAccountId = new Account.Id(999999);
+    Account.Id nonExistingAccountId = Account.id(999999);
     AtomicBoolean consumerCalled = new AtomicBoolean();
     Optional<AccountState> accountState =
         accountsUpdateProvider
@@ -475,10 +478,11 @@
           assertThat(tw).isNotNull();
           Config cfg = new Config();
           cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
-          assertThat(
-                  cfg.getString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME))
+          assertThat(cfg)
+              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
               .isEqualTo(name);
-          assertThat(cfg.getString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS))
+          assertThat(cfg)
+              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS)
               .isEqualTo(status);
         } else {
           // No account properties were set, hence an 'account.config' file was not created.
@@ -629,9 +633,10 @@
 
   @Test
   public void deactivateSelf() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot deactivate own account");
-    gApi.accounts().self().setActive(false);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().self().setActive(false));
+    assertThat(thrown).hasMessageThat().contains("cannot deactivate own account");
   }
 
   @Test
@@ -661,7 +666,7 @@
     assertThat(change.stars).contains(DEFAULT_LABEL);
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     gApi.accounts().self().unstarChange(triplet);
     change = info(triplet);
@@ -669,7 +674,7 @@
     assertThat(change.stars).isNull();
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     accountIndexedCounter.assertNoReindex();
   }
@@ -700,7 +705,7 @@
     assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     gApi.accounts()
         .self()
@@ -719,28 +724,36 @@
     assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id())));
+            allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
 
     accountIndexedCounter.assertNoReindex();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to get stars of another account");
-    gApi.accounts().id(Integer.toString((admin.id().get()))).getStars(triplet);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().id(Integer.toString((admin.id().get()))).getStars(triplet));
+    assertThat(thrown).hasMessageThat().contains("not allowed to get stars of another account");
   }
 
   @Test
   public void starWithInvalidLabels() throws Exception {
     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")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        triplet,
+                        new StarsInput(
+                            ImmutableSet.of(
+                                DEFAULT_LABEL, "invalid label", "blue", "another invalid label"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid labels: another invalid label, invalid label");
   }
 
   @Test
@@ -758,17 +771,24 @@
   public void starWithDefaultAndIgnoreLabel() throws Exception {
     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)));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        triplet,
+                        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + DEFAULT_LABEL
+                + " and "
+                + IGNORE_LABEL
+                + " are mutually exclusive."
+                + " Only one of them can be set.");
   }
 
   @Test
@@ -897,7 +917,7 @@
     requestScopeOperations.setApiUser(admin.id());
     String secondaryEmail = "secondary@example.com";
     EmailInput input = newEmailInput(secondaryEmail);
-    gApi.accounts().id(foo.id().hashCode()).addEmail(input);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
 
     requestScopeOperations.setApiUser(foo.id());
     assertThat(getEmails()).containsExactly(email, secondaryEmail);
@@ -909,9 +929,9 @@
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(foo.id().get()).getEmails();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(foo.id().get()).getEmails());
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -920,7 +940,7 @@
     String secondaryEmail = "secondary3@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
     EmailInput input = newEmailInput(secondaryEmail);
-    gApi.accounts().id(foo.id().hashCode()).addEmail(input);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
 
     assertThat(
             gApi.accounts().id(foo.id().get()).getEmails().stream()
@@ -976,8 +996,7 @@
     TestAccount account = accountCreator.create(name("user"));
     EmailInput input = newEmailInput("test@test.com");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(account.username()).addEmail(input);
+    assertThrows(AuthException.class, () -> gApi.accounts().id(account.username()).addEmail(input));
   }
 
   @Test
@@ -985,9 +1004,13 @@
     String email = "new.email@example.com";
     EmailInput input = newEmailInput(email);
     gApi.accounts().self().addEmail(input);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Identity 'mailto:" + email + "' in use by another account");
-    gApi.accounts().id(user.username()).addEmail(input);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.accounts().id(user.username()).addEmail(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Identity 'mailto:" + email + "' in use by another account");
   }
 
   @Test
@@ -1023,9 +1046,14 @@
     TestAccount user = accountCreator.create();
     requestScopeOperations.setApiUser(user.id());
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.id().get()).addEmail(newEmailInput("foo@example.com", false));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.accounts()
+                    .id(admin.id().get())
+                    .addEmail(newEmailInput("foo@example.com", false)));
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -1110,9 +1138,11 @@
     assertThat(getEmails()).doesNotContain(email);
 
     // user cannot delete email of admin
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.id().get()).deleteEmail(admin.email());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().id(admin.id().get()).deleteEmail(admin.email()));
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -1206,8 +1236,9 @@
   @Test
   public void userCannotSetNameOfOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).setName("Admin McAdminface");
+    assertThrows(
+        AuthException.class,
+        () -> gApi.accounts().id(admin.username()).setName("Admin McAdminface"));
   }
 
   @Test
@@ -1267,9 +1298,13 @@
 
     // 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.");
-    fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
+    TransportException thrown =
+        assertThrows(
+            TransportException.class,
+            () -> fetch(allUsersRepo, otherUserRefName + ":otherUserRef"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Remote does not have " + otherUserRefName + " available for fetch.");
   }
 
   @Test
@@ -1296,7 +1331,7 @@
     PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
     accountIndexedCounter.assertReindexOf(admin);
@@ -1305,7 +1340,7 @@
     r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
     accountIndexedCounter.assertReindexOf(admin);
@@ -1332,7 +1367,7 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
@@ -1370,7 +1405,7 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     requestScopeOperations.setApiUser(foo.id());
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
@@ -1402,20 +1437,24 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: commit '%s' has an invalid '%s' file for account '%s':"
-                + " Invalid config file %s in commit %s",
-            r.getCommit().name(),
-            AccountProperties.ACCOUNT_CONFIG,
-            admin.id(),
-            AccountProperties.ACCOUNT_CONFIG,
-            r.getCommit().name()));
-    gApi.changes().id(r.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "invalid account configuration: commit '%s' has an invalid '%s' file for account"
+                    + " '%s': Invalid config file %s in commit %s",
+                r.getCommit().name(),
+                AccountProperties.ACCOUNT_CONFIG,
+                admin.id(),
+                AccountProperties.ACCOUNT_CONFIG,
+                r.getCommit().name()));
   }
 
   @Test
@@ -1441,15 +1480,19 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: invalid preferred email '%s' for account '%s'",
-            noEmail, admin.id()));
-    gApi.changes().id(r.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "invalid account configuration: invalid preferred email '%s' for account '%s'",
+                noEmail, admin.id()));
   }
 
   @Test
@@ -1474,12 +1517,16 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("invalid account configuration: cannot deactivate own account");
-    gApi.changes().id(r.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid account configuration: cannot deactivate own account");
   }
 
   @Test
@@ -1492,7 +1539,7 @@
     accountIndexedCounter.clear();
 
     grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
-    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroupUuid(), false);
+    grantLabel("Code-Review", -2, 2, allUsers, userRef, adminGroupUuid(), false);
     grant(allUsers, userRef, Permission.SUBMIT, false, adminGroupUuid());
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
@@ -1513,7 +1560,7 @@
             .to(MagicBranch.NEW_CHANGE + userRef);
     r.assertOkStatus();
     accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
@@ -1776,7 +1823,7 @@
     grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
     grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
 
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
     r.assertErrorStatus();
@@ -1793,7 +1840,7 @@
     grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
     grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
 
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef).assertOkStatus();
 
@@ -1898,9 +1945,10 @@
     assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.accounts().self().gpgKey(id).get());
+    assertThat(thrown).hasMessageThat().contains(id);
   }
 
   @Test
@@ -1943,9 +1991,9 @@
     addGpgKey(key.getPublicKeyArmored());
     requestScopeOperations.setApiUser(user.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("GPG key already associated with another account");
-    addGpgKey(key.getPublicKeyArmored());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> addGpgKey(key.getPublicKeyArmored()));
+    assertThat(thrown).hasMessageThat().contains("GPG key already associated with another account");
   }
 
   @Test
@@ -1973,9 +2021,10 @@
     accountIndexedCounter.assertReindexOf(admin);
     assertKeys();
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.accounts().self().gpgKey(id).get());
+    assertThat(thrown).hasMessageThat().contains(id);
   }
 
   @Test
@@ -2009,20 +2058,25 @@
     assertKeys(key2, key5);
     accountIndexedCounter.assertReindexOf(admin);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
-    gApi.accounts()
-        .self()
-        .putGpgKeys(
-            ImmutableList.of(key2.getPublicKeyArmored()), ImmutableList.of(key2.getKeyIdString()));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .putGpgKeys(
+                        ImmutableList.of(key2.getPublicKeyArmored()),
+                        ImmutableList.of(key2.getKeyIdString())));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
   }
 
   @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);
+    BadRequestException thrown = assertThrows(BadRequestException.class, () -> addGpgKey(key));
+    assertThat(thrown).hasMessageThat().contains("Failed to parse GPG keys");
   }
 
   @Test
@@ -2104,9 +2158,9 @@
     accountIndexedCounter.assertReindexOf(user);
 
     // user cannot reindex any account
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.username()).index();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).index());
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -2171,7 +2225,7 @@
 
     // metaId is set when account is created
     AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     AccountState accountState = au.insert("Create Test Account", accountId, u -> {});
     assertThat(accountState.getAccount().getMetaId()).isEqualTo(getMetaId(accountId));
 
@@ -2456,7 +2510,7 @@
   public void atomicReadMofifyWriteExternalIds() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
 
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId extIdA1 = ExternalId.create("foo", "A-1", accountId);
     accountsUpdateProvider
         .get()
@@ -2535,7 +2589,7 @@
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
-    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    Account.Id accountId = Account.id(accountInfo._accountId);
     assertThat(stalenessChecker.isStale(accountId)).isFalse();
 
     // Manually updating the user ref makes the index document stale.
@@ -2713,7 +2767,7 @@
   @Test
   public void deleteDraftCommentsSkipsInvisibleChanges() throws Exception {
     try {
-      createBranch(new Branch.NameKey(project, "secret"));
+      createBranch(BranchNameKey.create(project, "secret"));
       PushOneCommit.Result r1 = createChange();
       PushOneCommit.Result r2 = createChange("refs/for/secret");
 
@@ -2755,22 +2809,23 @@
   @Test
   public void userCannotGenerateNewHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).generateHttpPassword();
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().id(admin.username()).generateHttpPassword());
   }
 
   @Test
   public void userCannotExplicitlySetHttpPassword() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().self().setHttpPassword("my-new-password");
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().self().setHttpPassword("my-new-password"));
   }
 
   @Test
   public void userCannotExplicitlySetHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).setHttpPassword("my-new-password");
+    assertThrows(
+        AuthException.class,
+        () -> gApi.accounts().id(admin.username()).setHttpPassword("my-new-password"));
   }
 
   @Test
@@ -2782,8 +2837,8 @@
   @Test
   public void userCannotRemoveHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.accounts().id(admin.username()).setHttpPassword(null);
+    assertThrows(
+        AuthException.class, () -> gApi.accounts().id(admin.username()).setHttpPassword(null));
   }
 
   @Test
@@ -2805,9 +2860,11 @@
     requestScopeOperations.setApiUser(admin.id());
     int userId = accountCreator.create().id().get();
     assertThat(gApi.accounts().id(userId).get().username).isNull();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("username");
-    gApi.accounts().id(userId).generateHttpPassword();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.accounts().id(userId).generateHttpPassword());
+    assertThat(thrown).hasMessageThat().contains("username");
   }
 
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
@@ -2834,12 +2891,9 @@
 
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<GroupInfo, String>() {
-          @Override
-          public boolean apply(GroupInfo actualGroup, String expectedName) {
-            String groupName = actualGroup == null ? null : actualGroup.name;
-            return Objects.equals(groupName, expectedName);
-          }
+        (actualGroup, expectedName) -> {
+          String groupName = actualGroup == null ? null : actualGroup.name;
+          return Objects.equals(groupName, expectedName);
         },
         "has name");
   }
@@ -2888,8 +2942,8 @@
     // Check via API.
     FluentIterable<TestKey> expected = FluentIterable.from(expectedKeys);
     Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
-    assertThat(keyMap.keySet())
-        .named("keys returned by listGpgKeys()")
+    assertWithMessage("keys returned by listGpgKeys()")
+        .that(keyMap.keySet())
         .containsExactlyElementsIn(expected.transform(TestKey::getKeyIdString));
 
     for (TestKey key : expected) {
@@ -2911,7 +2965,9 @@
         externalIds.byAccount(currAccountId, SCHEME_GPGKEY).stream()
             .map(e -> e.key().id())
             .collect(toSet());
-    assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
+    assertWithMessage("external IDs in database")
+        .that(actualFps)
+        .containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
     for (TestKey key : expected) {
@@ -2921,15 +2977,15 @@
 
   private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
     String id = expected.getKeyIdString();
-    assertThat(actual.id).named(id).isEqualTo(id);
-    assertThat(actual.fingerprint)
-        .named(id)
+    assertWithMessage(id).that(actual.id).isEqualTo(id);
+    assertWithMessage(id)
+        .that(actual.fingerprint)
         .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
     List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
-    assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds);
+    assertWithMessage(id).that(actual.userIds).containsExactlyElementsIn(userIds);
     String key = actual.key;
-    assertThat(key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
-    assertThat(key).named(id).endsWith("-----END PGP PUBLIC KEY BLOCK-----\n");
+    assertWithMessage(id).that(key).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    assertWithMessage(id).that(key).endsWith("-----END PGP PUBLIC KEY BLOCK-----\n");
     assertThat(actual.status).isEqualTo(GpgKeyInfo.Status.TRUSTED);
     assertThat(actual.problems).isEmpty();
   }
@@ -3022,7 +3078,7 @@
     }
 
     void assertReindexOf(AccountInfo accountInfo) {
-      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
+      assertReindexOf(Account.id(accountInfo._accountId), 1);
     }
 
     void assertReindexOf(TestAccount testAccount, int expectedCount) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index 60a61d1..7b20dbb 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -136,7 +136,7 @@
 
   private Account.Id createAccount(String name) throws RestApiException {
     AccountInfo account = gApi.accounts().create(name).get();
-    return new Account.Id(account._accountId);
+    return Account.id(account._accountId);
   }
 
   private void reloadAccountToCache(Account.Id accountId) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index edb98d0..07bd7ee 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.acceptance.api.accounts;
 
+import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -122,7 +125,7 @@
   @Test
   public void authenticateWithEmail() throws Exception {
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
     accountsUpdate.insert(
         "Create Test Account",
@@ -137,7 +140,7 @@
   @Test
   public void authenticateWithUsername() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -152,7 +155,7 @@
   @Test
   public void authenticateWithExternalUser() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -168,7 +171,7 @@
   public void authenticateWithUsernameAndUpdateEmail() throws Exception {
     String username = "foo";
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -196,7 +199,7 @@
   public void authenticateWithUsernameAndUpdateDisplayName() throws Exception {
     String username = "foo";
     String email = "foo@example.com";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -224,7 +227,7 @@
     assertNoSuchExternalIds(gerritExtIdKey);
 
     // Create orphaned SCHEME_GERRIT external ID.
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId gerritExtId = ExternalId.create(gerritExtIdKey, accountId);
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
@@ -234,15 +237,15 @@
     }
 
     AuthRequest who = AuthRequest.forUser(username);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account not found");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account not found");
   }
 
   @Test
   public void cannotAuthenticateWithInactiveAccount() throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -250,16 +253,16 @@
         u -> u.setActive(false).addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
 
     AuthRequest who = AuthRequest.forUser(username);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account inactive");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive");
   }
 
   @Test
   public void cannotActivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsDisabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -269,9 +272,9 @@
     AuthRequest who = AuthRequest.forUser(username);
     who.setActive(true);
     who.setAuthProvidesAccountActiveStatus(true);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Authentication error, account inactive");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive");
   }
 
   @Test
@@ -279,7 +282,7 @@
   public void activateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsEnabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -300,7 +303,7 @@
   public void cannotDeactivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsDisabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -322,7 +325,7 @@
   public void deactivateAccountOnAuthenticationWhenAutoUpdateAccountActiveStatusIsEnabled()
       throws Exception {
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -350,7 +353,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -360,9 +363,11 @@
     // Try to authenticate with this email to create a new account with a SCHEME_MAILTO external ID.
     // Expect that this fails because the email is already assigned to the other account.
     AuthRequest who = AuthRequest.forEmail(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   @Test
@@ -371,7 +376,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -382,9 +387,11 @@
     // Expect that this fails because the email is already assigned to the other account.
     AuthRequest who = AuthRequest.forUser("bar");
     who.setEmailAddress(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.authenticate(who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   @Test
@@ -394,7 +401,7 @@
 
     // Create an account with a SCHEME_GERRIT external ID and an email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -404,7 +411,7 @@
                 .addExternalId(ExternalId.createWithEmail(gerritExtIdKey, accountId, email)));
 
     // Create another account with an SCHEME_EXTERNAL external ID that occupies the new email.
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, "bar");
     accountsUpdate.insert(
         "Create Test Account",
@@ -437,7 +444,7 @@
   public void linkNewExternalId() throws Exception {
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -461,7 +468,7 @@
   public void updateExternalIdOnLink() throws Exception {
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -484,7 +491,7 @@
   public void cannotLinkExternalIdThatIsAlreadyUsed() throws Exception {
     // Create an account with a SCHEME_EXTERNAL external ID
     String username1 = "foo";
-    Account.Id accountId1 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId1 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey1 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username1);
     accountsUpdate.insert(
         "Create Test Account",
@@ -493,7 +500,7 @@
 
     // Create another account with a SCHEME_EXTERNAL external ID
     String username2 = "bar";
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey2 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username2);
     accountsUpdate.insert(
         "Create Test Account",
@@ -503,9 +510,11 @@
     // Try to link external ID of the first account to the second account.
     // Expect that this fails because the external ID is already assigned to the first account.
     AuthRequest who = AuthRequest.forExternalUser(username1);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Identity 'external:foo' in use by another account");
-    accountManager.link(accountId2, who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.link(accountId2, who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Identity 'external:foo' in use by another account");
   }
 
   @Test
@@ -514,7 +523,7 @@
 
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
@@ -523,7 +532,7 @@
 
     // Create another account with a SCHEME_GERRIT external ID and no email
     String username2 = "foo";
-    Account.Id accountId2 = new Account.Id(seq.nextAccountId());
+    Account.Id accountId2 = Account.id(seq.nextAccountId());
     ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username2);
     accountsUpdate.insert(
         "Create Test Account",
@@ -533,14 +542,19 @@
     // Try to link the email to the second account (via a new MAILTO external ID) and expect that
     // this fails because the email is already assigned to the first account.
     AuthRequest who = AuthRequest.forEmail(email);
-    exception.expect(AccountException.class);
-    exception.expectMessage("Email 'foo@example.com' in use by another account");
-    accountManager.link(accountId, who);
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.link(accountId, who));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
   }
 
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
     for (ExternalId.Key extIdKey : extIdKeys) {
-      assertThat(externalIds.get(extIdKey)).named(extIdKey.get()).isEmpty();
+      assertWithMessage(extIdKey.get())
+          .about(optionals())
+          .that(externalIds.get(extIdKey))
+          .isEmpty();
     }
   }
 
@@ -561,13 +575,15 @@
       @Nullable String expectedEmail)
       throws Exception {
     Optional<ExternalId> extId = externalIds.get(extIdKey);
-    assertThat(extId).named(extIdKey.get()).isPresent();
+    assertWithMessage(extIdKey.get()).about(optionals()).that(extId).isPresent();
     if (expectedAccountId != null) {
-      assertThat(extId.get().accountId())
-          .named("account ID of " + extIdKey.get())
+      assertWithMessage("account ID of " + extIdKey.get())
+          .that(extId.get().accountId())
           .isEqualTo(expectedAccountId);
     }
-    assertThat(extId.get().email()).named("email of " + extIdKey.get()).isEqualTo(expectedEmail);
+    assertWithMessage("email of " + extIdKey.get())
+        .that(extId.get().email())
+        .isEqualTo(expectedEmail);
   }
 
   private void assertAuthResultForNewAccount(
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index a4a5745..382b24b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
@@ -81,7 +82,7 @@
     AccountGroup.UUID g = groupOperations.newGroup().name(name).create();
     GroupApi groupApi = gApi.groups().id(g.get());
     groupApi.description("CLA test group");
-    InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
+    InternalGroup caGroup = group(AccountGroup.uuid(groupApi.detail().id));
     GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
     PermissionRule rule = new PermissionRule(groupRef);
     rule.setAction(PermissionRule.Action.ALLOW);
@@ -145,17 +146,21 @@
   @Test
   public void signNonExistingAgreement() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("contributor agreement not found");
-    gApi.accounts().self().signAgreement("does-not-exist");
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.accounts().self().signAgreement("does-not-exist"));
+    assertThat(thrown).hasMessageThat().contains("contributor agreement not found");
   }
 
   @Test
   public void signAgreementNoAutoVerify() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot enter a non-autoVerify agreement");
-    gApi.accounts().self().signAgreement(caNoAutoVerify.getName());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().signAgreement(caNoAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("cannot enter a non-autoVerify agreement");
   }
 
   @Test
@@ -188,33 +193,40 @@
   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());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().id("admin").signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("not allowed to enter contributor agreement");
   }
 
   @Test
   public void signAgreementAnonymous() throws Exception {
     requestScopeOperations.setApiUserAnonymous();
-    exception.expect(AuthException.class);
-    exception.expectMessage("Authentication required");
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.accounts().self().signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
   @Test
   public void agreementsDisabledSign() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.accounts().self().signAgreement(caAutoVerify.getName()));
+    assertThat(thrown).hasMessageThat().contains("contributor agreements disabled");
   }
 
   @Test
   public void agreementsDisabledList() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().listAgreements();
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class, () -> gApi.accounts().self().listAgreements());
+    assertThat(thrown).hasMessageThat().contains("contributor agreements disabled");
   }
 
   @Test
@@ -233,9 +245,9 @@
     // Revert is not allowed when CLA is required but not signed
     requestScopeOperations.setApiUser(user.id());
     setUseContributorAgreements(InheritableBoolean.TRUE);
-    exception.expect(AuthException.class);
-    exception.expectMessage("Contributor Agreement");
-    gApi.changes().id(change.changeId).revert();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(change.changeId).revert());
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
   }
 
   @Test
@@ -287,9 +299,10 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = dest.ref;
     in.message = change.subject;
-    exception.expect(AuthException.class);
-    exception.expectMessage("Contributor Agreement");
-    gApi.changes().id(change.changeId).current().cherryPick(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(change.changeId).current().cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 70e37ef..12266c9 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -160,9 +161,11 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem(null, "url"));
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name for menu item is required");
-    gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown).hasMessageThat().contains("name for menu item is required");
   }
 
   @Test
@@ -171,9 +174,11 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", null));
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("URL for menu item is required");
-    gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown).hasMessageThat().contains("URL for menu item is required");
   }
 
   @Test
@@ -191,9 +196,13 @@
     GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
     i.downloadScheme = "foo";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Unsupported download scheme: " + i.downloadScheme);
-    gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Unsupported download scheme: " + i.downloadScheme);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index 925c66a..6a116d8 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
@@ -59,9 +60,9 @@
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).abandon();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).abandon());
+    assertThat(thrown).hasMessageThat().contains("change is abandoned");
   }
 
   @Test
@@ -89,17 +90,23 @@
     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(Project.nameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(Project.nameKey(project2Name));
 
     CurrentUser user = atrScope.get().getUser();
     PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
     PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
     List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
-    batchAbandon.batchAbandon(batchUpdateFactory, new Project.NameKey(project1Name), user, list);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                batchAbandon.batchAbandon(
+                    batchUpdateFactory, Project.nameKey(project1Name), user, list));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
   }
 
   @Test
@@ -157,9 +164,9 @@
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("abandon not permitted");
-    gApi.changes().id(changeId).abandon();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).abandon());
+    assertThat(thrown).hasMessageThat().contains("abandon not permitted");
   }
 
   @Test
@@ -188,9 +195,9 @@
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is new");
-    gApi.changes().id(changeId).restore();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).restore());
+    assertThat(thrown).hasMessageThat().contains("change is new");
   }
 
   @Test
@@ -201,9 +208,9 @@
     gApi.changes().id(changeId).abandon();
     requestScopeOperations.setApiUser(user.id());
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-    exception.expect(AuthException.class);
-    exception.expectMessage("restore not permitted");
-    gApi.changes().id(changeId).restore();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).restore());
+    assertThat(thrown).hasMessageThat().contains("restore not permitted");
   }
 
   private List<Integer> toChangeNumbers(List<ChangeInfo> changes) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index ebb3978..d9699fd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/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.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
@@ -46,6 +47,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.Util.category;
 import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.joining;
@@ -135,7 +137,7 @@
 import com.google.gerrit.mail.Address;
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -275,9 +277,9 @@
     String changeId = rwip.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(changeId).setWorkInProgress();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setWorkInProgress());
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -321,9 +323,9 @@
     gApi.changes().id(changeId).setWorkInProgress();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(changeId).setReadyForReview();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setReadyForReview());
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -620,9 +622,10 @@
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -646,9 +649,10 @@
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore().setReady(true);
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("toggle work in progress state not permitted");
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
@@ -672,9 +676,9 @@
     PushOneCommit.Result r2 = push2.to("refs/for/other");
     assertThat(r2.getChangeId()).isEqualTo(changeId);
 
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Multiple changes found for " + changeId);
-    gApi.changes().id(changeId).get();
+    ResourceNotFoundException thrown =
+        assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(changeId).get());
+    assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
   }
 
   @Test
@@ -768,9 +772,10 @@
     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();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(r.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains("Cannot revert initial commit");
   }
 
   @FunctionalInterface
@@ -825,9 +830,10 @@
     assertThat(cr.all.get(0).value).isEqualTo(1);
 
     // 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();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
+    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
   }
 
   @Test
@@ -930,9 +936,9 @@
     // Rebase the second
     String changeId = r2.getChangeId();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -973,9 +979,9 @@
     // Rebase the second
     String changeId = r2.getChangeId();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -994,9 +1000,9 @@
 
     // Rebase the second
     String changeId = r2.getChangeId();
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
+    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
   }
 
   @Test
@@ -1012,9 +1018,9 @@
     String changeId = changeResult.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
@@ -1035,7 +1041,7 @@
     in.createEmptyCommit = true;
     ProjectApi api = gApi.projects().create(in);
 
-    Project.NameKey nameKey = new Project.NameKey(api.get().name);
+    Project.NameKey nameKey = Project.nameKey(api.get().name);
 
     try (ProjectConfigUpdate u = updateProject(nameKey)) {
       Util.allow(u.getConfig(), Permission.DELETE_CHANGES, PROJECT_OWNERS, "refs/*");
@@ -1087,7 +1093,7 @@
 
       assertThat(query(changeId)).isEmpty();
 
-      String ref = new Change.Id(id).toRefPrefix() + "1";
+      String ref = Change.id(id).toRefPrefix() + "1";
       eventRecorder.assertRefUpdatedEvents(projectName.get(), ref, null, commit, commit, null);
       eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email());
     } finally {
@@ -1110,9 +1116,9 @@
       String changeId = changeResult.getChangeId();
 
       requestScopeOperations.setApiUser(user.id());
-      exception.expect(AuthException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
+      AuthException thrown =
+          assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+      assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
       removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
     }
@@ -1139,9 +1145,9 @@
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).abandon();
 
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
@@ -1165,9 +1171,9 @@
 
     merge(changeResult);
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
@@ -1183,9 +1189,9 @@
       merge(changeResult);
 
       requestScopeOperations.setApiUser(user.id());
-      exception.expect(MethodNotAllowedException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
+      MethodNotAllowedException thrown =
+          assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+      assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
       removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
     }
@@ -1200,10 +1206,11 @@
     merge(changeResult);
     setChangeStatus(id, Change.Status.NEW);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Cannot delete change %s: patch set 1 is already merged", id));
-    gApi.changes().id(changeId).delete();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).delete());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot delete change %s: patch set 1 is already merged", id));
   }
 
   @Test
@@ -1254,16 +1261,21 @@
   @Test
   public void rebaseUpToDateChange() throws Exception {
     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();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
+    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
   }
 
   @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();
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
     PushOneCommit push =
         pushFactory.create(
@@ -1273,11 +1285,11 @@
             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();
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+    r2.assertOkStatus();
+    assertThrows(
+        ResourceConflictException.class,
+        () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
   }
 
   @Test
@@ -1291,23 +1303,23 @@
     ri.base = "";
     gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
     PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.getId().get()).isEqualTo(2);
+    assertThat(ps3.id().get()).isEqualTo(2);
 
     // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.getId().toRefName();
+    ri.base = ps3.id().toRefName();
     gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
     PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.getId().get()).isEqualTo(2);
+    assertThat(ps2.id().get()).isEqualTo(2);
 
     // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.getRevision().get();
+    ri.base = ps2.commitId().name();
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
     PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.getId().get()).isEqualTo(2);
+    assertThat(ps1.id().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.commitId().name()).rebase(ri);
     assertThat(r1.getPatchSetId().get()).isEqualTo(3);
   }
 
@@ -1322,9 +1334,11 @@
         "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);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 
   @Test
@@ -1336,9 +1350,11 @@
     ChangeInfo info = info(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();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
+    assertThat(thrown).hasMessageThat().contains("change is abandoned");
   }
 
   @Test
@@ -1358,9 +1374,11 @@
     RebaseInput ri = new RebaseInput();
     ri.base = r.getCommit().name();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("base change is abandoned: " + changeId);
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
   }
 
   @Test
@@ -1370,9 +1388,11 @@
     String commit = r.getCommit().name();
     RebaseInput ri = new RebaseInput();
     ri.base = commit;
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot rebase change onto itself");
-    gApi.changes().id(changeId).revision(commit).rebase(ri);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
+    assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
   }
 
   @Test
@@ -2055,8 +2075,8 @@
     comment.message = "comment 1";
     review.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
 
-    exception.expect(BadRequestException.class);
-    gApi.changes().id(changeId).current().review(review);
+    assertThrows(
+        BadRequestException.class, () -> gApi.changes().id(changeId).current().review(review));
   }
 
   @Test
@@ -2123,8 +2143,9 @@
     // Remove again, and then try to remove once more to verify 404 is
     // returned.
     gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).reviewer(user.id().toString()).remove());
   }
 
   @Test
@@ -2185,9 +2206,11 @@
     gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -2203,9 +2226,11 @@
     gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer("self").remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer("self").remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -2237,9 +2262,11 @@
     gApi.changes().id(changeId).abandon();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -2347,9 +2374,15 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete vote not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).deleteVote("Code-Review");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(admin.id().toString())
+                    .deleteVote("Code-Review"));
+    assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
   }
 
   @Test
@@ -2599,9 +2632,10 @@
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit topic name not permitted");
-    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).topic("mytopic"));
+    assertThat(thrown).hasMessageThat().contains("edit topic name not permitted");
   }
 
   @Test
@@ -2654,9 +2688,11 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit());
+    assertThat(thrown).hasMessageThat().contains("submit not permitted");
   }
 
   @Test
@@ -2749,7 +2785,7 @@
             "gerrit",
             (newCommitMessage, original, mergeTip, destination) -> {
               assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-              return newCommitMessage + "Custom: " + destination.get();
+              return newCommitMessage + "Custom: " + destination.branch();
             });
     ChangeInfo actual;
     try {
@@ -2866,9 +2902,9 @@
 
     info = gApi.changes().id(info._number).get();
     assertThat(info.changeId).isEqualTo(r.getChangeId());
-
-    exception.expect(AuthException.class);
-    gApi.changes().id(triplet).current().review(ReviewInput.approve());
+    assertThrows(
+        AuthException.class,
+        () -> gApi.changes().id(triplet).current().review(ReviewInput.approve()));
   }
 
   @Test
@@ -2882,7 +2918,7 @@
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commitPatchSetCreation =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+          rw.parseCommit(repo.exactRef(changeMetaRef(Change.id(c._number))).getObjectId());
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
@@ -2925,8 +2961,7 @@
     in.project = project.get();
     in.newBranch = true;
 
-    exception.expect(ResourceConflictException.class);
-    gApi.changes().create(in).get();
+    assertThrows(ResourceConflictException.class, () -> gApi.changes().create(in).get());
   }
 
   @Test
@@ -2947,12 +2982,12 @@
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().refName() + ":ps");
     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 + ".");
+    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().get() + ".");
   }
 
   @Test
@@ -2967,7 +3002,7 @@
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().refName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
@@ -2991,7 +3026,7 @@
     r1.assertOkStatus();
 
     // Fetch change
-    GitUtil.fetch(adminTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(adminTestRepo, r1.getPatchSet().refName() + ":ps");
     adminTestRepo.reset("ps");
 
     // Amend change as admin
@@ -3094,9 +3129,14 @@
     testRepo.reset(initialHead);
     String changeId = createChange().getChangeId();
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Read not permitted for " + baseChange);
-    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
+    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
   }
 
   @Test
@@ -3433,9 +3473,10 @@
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Code-Style", 1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Code-Style\" is not a configured label");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Code-Style\" is not a configured label");
   }
 
   @Test
@@ -3444,9 +3485,10 @@
     String changeId = createChange().getChangeId();
     ReviewInput in = new ReviewInput().label("Code-Review", 3);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Code-Review\": 3 is not a valid value");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Code-Review\": 3 is not a valid value");
   }
 
   @Test
@@ -3474,10 +3516,14 @@
 
     gApi.changes().id(result1.getChangeId()).current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs All-Comments-Resolved");
-    gApi.changes().id(result2.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result2.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs All-Comments-Resolved");
   }
 
   @Test
@@ -3488,10 +3534,14 @@
     PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     approve(r1.getChangeId());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(r1.getChangeId()).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
   }
 
   @Test
@@ -3506,10 +3556,13 @@
     amendChange(revertId);
     approve(revertId);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(revertId).current().submit();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(revertId).current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to submit 1 change due to the following problems");
+    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
   }
 
   @Test
@@ -3587,9 +3640,11 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("missing Change-Id footer");
-    gApi.changes().id(r.getChangeId()).setMessage("modified commit\n");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).setMessage("modified commit\n"));
+    assertThat(thrown).hasMessageThat().contains("missing Change-Id footer");
   }
 
   @Test
@@ -3597,11 +3652,14 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("NUL character");
-    gApi.changes()
-        .id(r.getChangeId())
-        .setMessage("test\0commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .setMessage("test\0commit\n\nChange-Id: " + r.getChangeId() + "\n"));
+    assertThat(thrown).hasMessageThat().contains("NUL character");
   }
 
   @Test
@@ -3610,11 +3668,15 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("wrong Change-Id footer");
-    gApi.changes()
-        .id(r.getChangeId())
-        .setMessage("modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .setMessage(
+                        "modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n"));
+    assertThat(thrown).hasMessageThat().contains("wrong Change-Id footer");
   }
 
   @Test
@@ -3629,9 +3691,10 @@
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     // Try to change the commit message
-    exception.expect(AuthException.class);
-    exception.expectMessage("modifying commit message not permitted");
-    gApi.changes().id(r.getChangeId()).setMessage("foo");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).setMessage("foo"));
+    assertThat(thrown).hasMessageThat().contains("modifying commit message not permitted");
   }
 
   @Test
@@ -3639,9 +3702,11 @@
     PushOneCommit.Result r = createChange();
     assertThat(getCommitMessage(r.getChangeId()))
         .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("new and existing commit message are the same");
-    gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId())));
+    assertThat(thrown).hasMessageThat().contains("new and existing commit message are the same");
   }
 
   @Test
@@ -3729,9 +3794,11 @@
     PushOneCommit.Result r1 = createChange();
     merge(r1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid object ID");
-    gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id");
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id"));
+    assertThat(thrown).hasMessageThat().contains("invalid object ID");
   }
 
   @Test
@@ -3776,9 +3843,11 @@
 
   @Test
   public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("revertOf not set");
-    gApi.changes().id(createChange().getChangeId()).pureRevert();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(createChange().getChangeId()).pureRevert());
+    assertThat(thrown).hasMessageThat().contains("revertOf not set");
   }
 
   @Test
@@ -3786,9 +3855,9 @@
     String changeId = createChange().getChangeId();
     String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("topic length exceeds the limit");
-    gApi.changes().id(changeId).topic(topic);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).topic(topic));
+    assertThat(thrown).hasMessageThat().contains("topic length exceeds the limit");
   }
 
   @Test
@@ -3886,7 +3955,7 @@
     if (r == null) {
       return ImmutableList.of();
     }
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 
   private ChangeResource parseResource(PushOneCommit.Result r) throws Exception {
@@ -3901,7 +3970,7 @@
             .filter(e -> e.getValue().stream().anyMatch(a -> a._accountId == accountId.get()))
             .map(Map.Entry::getKey)
             .collect(toSet());
-    assertThat(states.size()).named(states.toString()).isAtMost(1);
+    assertWithMessage(states.toString()).that(states.size()).isAtMost(1);
     return states.stream().findFirst();
   }
 
@@ -4064,9 +4133,9 @@
   public void cannotIgnoreOwnChange() throws Exception {
     String changeId = createChange().getChangeId();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot ignore own change");
-    gApi.changes().id(changeId).ignore(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).ignore(true));
+    assertThat(thrown).hasMessageThat().contains("cannot ignore own change");
   }
 
   @Test
@@ -4077,14 +4146,17 @@
     gApi.accounts().self().starChange(changeId);
     assertThat(gApi.changes().id(changeId).get().starred).isTrue();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.changes().id(changeId).ignore(true);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).ignore(true));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.DEFAULT_LABEL
+                + " and "
+                + StarredChangesUtil.IGNORE_LABEL
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4095,14 +4167,17 @@
     gApi.changes().id(changeId).ignore(true);
     assertThat(gApi.changes().id(changeId).ignored()).isTrue();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts().self().starChange(changeId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.accounts().self().starChange(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.DEFAULT_LABEL
+                + " and "
+                + StarredChangesUtil.IGNORE_LABEL
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4140,21 +4215,28 @@
     gApi.changes().id(changeId).markAsReviewed(true);
     assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        changeId,
+                        new StarsInput(
+                            ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.REVIEWED_LABEL
+                + "/"
+                + 1
+                + " and "
+                + StarredChangesUtil.UNREVIEWED_LABEL
+                + "/"
+                + 1
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4165,21 +4247,27 @@
     gApi.changes().id(changeId).markAsReviewed(false);
     assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1")));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(
+                        changeId,
+                        new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1"))));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The labels "
+                + StarredChangesUtil.REVIEWED_LABEL
+                + "/"
+                + 1
+                + " and "
+                + StarredChangesUtil.UNREVIEWED_LABEL
+                + "/"
+                + 1
+                + " are mutually exclusive. Only one of them can be set.");
   }
 
   @Test
@@ -4208,9 +4296,14 @@
 
     // label cannot contain whitespace
     String invalidLabel = "invalid label";
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid labels: " + invalidLabel);
-    gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel))));
+    assertThat(thrown).hasMessageThat().contains("invalid labels: " + invalidLabel);
   }
 
   @Test
@@ -4243,7 +4336,7 @@
   }
 
   private BranchApi createBranch(String branch) throws Exception {
-    return createBranch(new Branch.NameKey(project, branch));
+    return createBranch(BranchNameKey.create(project, branch));
   }
 
   private ThrowableSubject assertThatQueryException(String query) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index 7899ecd..789a7c7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -56,16 +57,22 @@
 
   @Test
   public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo._number);
-    gApi.changes().id("unknown", changeInfo._number);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id("unknown", changeInfo._number));
+    assertThat(thrown).hasMessageThat().contains("Not found: unknown~" + changeInfo._number);
   }
 
   @Test
   public void wrongIdInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
-    gApi.changes().id(project.get(), Integer.MAX_VALUE);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), Integer.MAX_VALUE));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
   }
 
   @Test
@@ -76,8 +83,7 @@
 
   @Test
   public void wrongChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(Integer.MAX_VALUE);
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(Integer.MAX_VALUE));
   }
 
   @Test
@@ -88,25 +94,36 @@
 
   @Test
   public void wrongProjectInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
-    gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
   }
 
   @Test
   public void wrongBranchInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
-    gApi.changes().id(project.get(), "unknown", changeInfo.changeId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), "unknown", changeInfo.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
   }
 
   @Test
   public void wrongIdInTripletChangeIdReturnsNotFound() throws Exception {
     String unknownId = "I1234567890";
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(
-        "Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
-    gApi.changes().id(project.get(), changeInfo.branch, unknownId);
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(project.get(), changeInfo.branch, unknownId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
   }
 
   @Test
@@ -121,8 +138,7 @@
 
   @Test
   public void wrongChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id("I1234567890");
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id("I1234567890"));
   }
 
   @Test
@@ -139,11 +155,13 @@
     // IHash throws
     ChangeInfo ci =
         gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
-    exception.expect(DeprecatedIdentifierException.class);
-    exception.expectMessage(
-        "The provided change identifier "
-            + ci.changeId
-            + " is deprecated. Use 'project~changeNumber' instead.");
-    gApi.changes().id(ci.changeId);
+    DeprecatedIdentifierException thrown =
+        assertThrows(DeprecatedIdentifierException.class, () -> gApi.changes().id(ci.changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The provided change identifier "
+                + ci.changeId
+                + " is deprecated. Use 'project~changeNumber' instead.");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
index ae88afd..eab6c3c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -31,9 +32,9 @@
   public void createPrivateChangeWithDisablePrivateChangesTrue() throws Exception {
     ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
     input.isPrivate = true;
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().create(input));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
@@ -107,9 +108,11 @@
   public void setPrivateWithDisablePrivateChangesTrue() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().id(result.getChangeId()).setPrivate(true, "set private");
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.changes().id(result.getChangeId()).setPrivate(true, "set private"));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java b/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
index a08d417..c5765da 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
@@ -152,18 +154,26 @@
   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"));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .edit()
+                    .modifyFile(MERGE_LIST, RawInputUtil.create("new content")));
+    assertThat(thrown).hasMessageThat().contains("Invalid path: " + MERGE_LIST);
   }
 
   @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);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().deleteFile(MERGE_LIST));
+    assertThat(thrown).hasMessageThat().contains("no changes were made");
   }
 
   private String getMergeListContent(RevCommit... commits) {
@@ -171,7 +181,7 @@
     for (RevCommit c : commits) {
       mergeList
           .append("* ")
-          .append(c.abbreviate(8).name())
+          .append(abbreviateName(c, 8))
           .append(" ")
           .append(c.getShortMessage())
           .append("\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index bfcb1a8..ab72491 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -89,9 +90,9 @@
     String changeId = result.getChangeId();
     assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot set merged change to private");
-    gApi.changes().id(changeId).setPrivate(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).setPrivate(true));
+    assertThat(thrown).hasMessageThat().contains("cannot set merged change to private");
   }
 
   @Test
@@ -102,9 +103,9 @@
     gApi.changes().id(changeId).abandon();
     assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot set abandoned change to private");
-    gApi.changes().id(changeId).setPrivate(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).setPrivate(true));
+    assertThat(thrown).hasMessageThat().contains("cannot set abandoned change to private");
   }
 
   @Test
@@ -126,9 +127,11 @@
   public void cannotSetOtherUsersChangePrivate() throws Exception {
     PushOneCommit.Result result = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(result.getChangeId()).setPrivate(true, null));
+    assertThat(thrown).hasMessageThat().contains("not allowed to mark private");
   }
 
   @Test
@@ -153,9 +156,10 @@
     gApi.changes().id(result.getChangeId()).reviewer(admin.id().toString()).remove();
 
     // This change should not be visible for admin anymore.
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + result.getChangeId());
-    gApi.changes().id(result.getChangeId());
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()));
+    assertThat(thrown).hasMessageThat().contains("Not found: " + result.getChangeId());
   }
 
   @Test
@@ -173,7 +177,7 @@
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     merge(result);
-    markMergedChangePrivate(new Change.Id(gApi.changes().id(changeId).get()._number));
+    markMergedChangePrivate(Change.id(gApi.changes().id(changeId).get()._number));
 
     gApi.changes().id(changeId).setPrivate(false, null);
     assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
@@ -191,9 +195,9 @@
     merge(result);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(changeId).setPrivate(true, null);
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setPrivate(true, null));
+    assertThat(thrown).hasMessageThat().contains("not allowed to mark private");
   }
 
   @Test
@@ -218,7 +222,7 @@
     String changeId = result.getChangeId();
     gApi.changes().id(changeId).addReviewer(admin.id().toString());
     merge(result);
-    markMergedChangePrivate(new Change.Id(gApi.changes().id(changeId).get()._number));
+    markMergedChangePrivate(Change.id(gApi.changes().id(changeId).get()._number));
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).setPrivate(false, null);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index bc4dca9..5052b15 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.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.Truth.assertWithMessage;
 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;
@@ -599,6 +600,6 @@
     if (changeKind != null) {
       name += "; changeKind = " + changeKind.name();
     }
-    assertThat(vote).named(name).isEqualTo(expectedVote);
+    assertWithMessage(name).that(vote).isEqualTo(expectedVote);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
index fd08838..70aa557 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -37,7 +37,7 @@
     DiffPreferencesInfo update = new DiffPreferencesInfo();
     update.lineLength = newLineLength;
     DiffPreferencesInfo result = gApi.config().server().setDefaultDiffPreferences(update);
-    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+    assertWithMessage("lineLength").that(result.lineLength).isEqualTo(newLineLength);
 
     result = gApi.config().server().getDefaultDiffPreferences();
     DiffPreferencesInfo expected = DiffPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
index e89aa3d..02f1ec3 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -37,7 +37,7 @@
     EditPreferencesInfo update = new EditPreferencesInfo();
     update.lineLength = newLineLength;
     EditPreferencesInfo result = gApi.config().server().setDefaultEditPreferences(update);
-    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+    assertWithMessage("lineLength").that(result.lineLength).isEqualTo(newLineLength);
 
     result = gApi.config().server().getDefaultEditPreferences();
     EditPreferencesInfo expected = EditPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
index c606982..221e171 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.config;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -36,7 +36,7 @@
     GeneralPreferencesInfo update = new GeneralPreferencesInfo();
     update.signedOffBy = newSignedOffBy;
     GeneralPreferencesInfo result = gApi.config().server().setDefaultPreferences(update);
-    assertThat(result.signedOffBy).named("signedOffBy").isEqualTo(newSignedOffBy);
+    assertWithMessage("signedOffBy").that(result.signedOffBy).isEqualTo(newSignedOffBy);
 
     result = gApi.config().server().getDefaultPreferences();
     GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults();
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index d7d311b..20f7e33 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -57,7 +57,7 @@
   @Test
   public void indexingUpdatesTheIndex() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("users");
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -74,7 +74,7 @@
   public void indexCannotBeCorruptedByStaleCache() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
     loadGroupToCache(groupUuid);
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -102,7 +102,7 @@
   @Test
   public void reindexingStaleGroupUpdatesTheIndex() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("users");
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
         newGroupUpdate()
@@ -139,7 +139,7 @@
 
   private AccountGroup.UUID createGroup(String name) throws RestApiException {
     GroupInfo group = gApi.groups().create(name).get();
-    return new AccountGroup.UUID(group.id);
+    return AccountGroup.uuid(group.id);
   }
 
   private void reloadGroupToCache(AccountGroup.UUID groupUuid) {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index e269e68..cdcfc0c 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -94,7 +94,7 @@
   public void missingGroupRef() throws Exception {
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      RefUpdate ru = repo.updateRef(RefNames.refsGroups(new AccountGroup.UUID(g1.id)));
+      RefUpdate ru = repo.updateRef(RefNames.refsGroups(AccountGroup.uuid(g1.id)));
       ru.setForceUpdate(true);
       RefUpdate.Result result = ru.delete();
       assertThat(result).isEqualTo(Result.FORCED);
@@ -109,7 +109,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefRename ru =
           repo.renameRef(
-              RefNames.refsGroups(new AccountGroup.UUID(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
+              RefNames.refsGroups(AccountGroup.uuid(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
       RefUpdate.Result result = ru.rename();
       assertThat(result).isEqualTo(Result.RENAMED);
     }
@@ -123,8 +123,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefRename ru =
           repo.renameRef(
-              RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-              RefNames.refsGroups(new AccountGroup.UUID(BOGUS_UUID)));
+              RefNames.refsGroups(AccountGroup.uuid(g1.id)),
+              RefNames.refsGroups(AccountGroup.uuid(BOGUS_UUID)));
       RefUpdate.Result result = ru.rename();
       assertThat(result).isEqualTo(Result.RENAMED);
     }
@@ -135,7 +135,7 @@
   @Test
   public void groupRefDoesNotParse() throws Exception {
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)),
         GroupConfig.GROUP_CONFIG_FILE,
         "[this is not valid\n");
     assertError("does not parse");
@@ -145,7 +145,7 @@
   public void nameRefDoesNotParse() throws Exception {
     updateGroupFile(
         RefNames.REFS_GROUPNAMES,
-        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g1.name)).getName(),
+        GroupNameNotes.getNoteKey(AccountGroup.nameKey(g1.name)).getName(),
         "[this is not valid\n");
     assertError("does not parse");
   }
@@ -158,9 +158,7 @@
     cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("inconsistent name");
   }
 
@@ -172,9 +170,7 @@
     cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("shared group id");
   }
 
@@ -186,9 +182,7 @@
     cfg.setString("group", null, "ownerGroupUuid", BOGUS_UUID);
 
     updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
-        GroupConfig.GROUP_CONFIG_FILE,
-        cfg.toText());
+        RefNames.refsGroups(AccountGroup.uuid(g1.id)), GroupConfig.GROUP_CONFIG_FILE, cfg.toText());
     assertError("nonexistent owner group");
   }
 
@@ -201,27 +195,26 @@
 
     updateGroupFile(
         RefNames.REFS_GROUPNAMES,
-        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(bogusName)).getName(),
+        GroupNameNotes.getNoteKey(AccountGroup.nameKey(bogusName)).getName(),
         config.toText());
     assertError("entry missing as group ref");
   }
 
   @Test
   public void nonexistentMember() throws Exception {
-    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "members", "314159265\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "members", "314159265\n");
     assertError("nonexistent member 314159265");
   }
 
   @Test
   public void nonexistentSubgroup() throws Exception {
-    updateGroupFile(
-        RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", BOGUS_UUID + "\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "subgroups", BOGUS_UUID + "\n");
     assertError("has nonexistent subgroup");
   }
 
   @Test
   public void cyclicSubgroup() throws Exception {
-    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", g1.id + "\n");
+    updateGroupFile(RefNames.refsGroups(AccountGroup.uuid(g1.id)), "subgroups", g1.id + "\n");
     assertWarning("cycle");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 47ac7a9..28ebadb 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
@@ -23,6 +24,7 @@
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 import static java.util.stream.Collectors.toList;
@@ -31,7 +33,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -89,6 +90,7 @@
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -168,14 +170,16 @@
 
   @Test
   public void addToNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").addMembers("admin");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.groups().id("non-existing").addMembers("admin"));
   }
 
   @Test
   public void removeFromNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").removeMembers("admin");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.groups().id("non-existing").removeMembers("admin"));
   }
 
   @Test
@@ -215,7 +219,7 @@
   @Test
   public void cachedGroupByNameIsUpdatedOnCreation() throws Exception {
     String newGroupName = name("newGroup");
-    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(newGroupName);
+    AccountGroup.NameKey nameKey = AccountGroup.nameKey(newGroupName);
     assertThat(groupCache.get(nameKey)).isEmpty();
     gApi.groups().create(newGroupName);
     assertThat(groupCache.get(nameKey)).isPresent();
@@ -231,8 +235,9 @@
 
   @Test
   public void addNonExistingMember_UnprocessableEntity() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id("Administrators").addMembers("non-existing");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id("Administrators").addMembers("non-existing"));
   }
 
   @Test
@@ -351,9 +356,9 @@
   public void createDuplicateInternalGroupCaseSensitiveName_Conflict() throws Exception {
     String dupGroupName = name("dupGroup");
     gApi.groups().create(dupGroupName);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group '" + dupGroupName + "' already exists");
-    gApi.groups().create(dupGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(dupGroupName));
+    assertThat(thrown).hasMessageThat().contains("group '" + dupGroupName + "' already exists");
   }
 
   @Test
@@ -369,33 +374,34 @@
   @Test
   public void createDuplicateSystemGroupCaseSensitiveName_Conflict() throws Exception {
     String newGroupName = "Registered Users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(newGroupName));
+    assertThat(thrown).hasMessageThat().contains("group 'Registered Users' already exists");
   }
 
   @Test
   public void createDuplicateSystemGroupCaseInsensitiveName_Conflict() throws Exception {
     String newGroupName = "registered users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create(newGroupName));
+    assertThat(thrown).hasMessageThat().contains("group 'Registered Users' already exists");
   }
 
   @Test
   @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");
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.groups().create("all users"));
+    assertThat(thrown).hasMessageThat().contains("group 'All Users' already exists");
   }
 
   @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");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.groups().create("anonymous users"));
+    assertThat(thrown).hasMessageThat().contains("group name 'Anonymous Users' is reserved");
   }
 
   @Test
@@ -414,8 +420,7 @@
   @Test
   public void createGroupWithoutCapability_Forbidden() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.groups().create(name("newGroup"));
+    assertThrows(AuthException.class, () -> gApi.groups().create(name("newGroup")));
   }
 
   @Test
@@ -441,7 +446,7 @@
     GroupInfo group = gApi.groups().create(groupInput).get();
 
     Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(accountId);
-    assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
+    assertThat(groups).containsExactly(AccountGroup.uuid(group.id));
   }
 
   @Test
@@ -481,8 +486,7 @@
   @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();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id("Anonymous-Users").get());
   }
 
   @Test
@@ -498,8 +502,7 @@
     String name = name("Users");
     gApi.groups().create(name).get();
 
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().create(name);
+    assertThrows(ResourceConflictException.class, () -> gApi.groups().create(name));
   }
 
   @Test
@@ -528,9 +531,7 @@
 
     String name2 = name("Name2");
     gApi.groups().create(name2);
-
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().id(group1.id).name(name2);
+    assertThrows(ResourceConflictException.class, () -> gApi.groups().id(group1.id).name(name2));
   }
 
   @Test
@@ -554,8 +555,7 @@
     gApi.groups().id(group.id).name(newName);
 
     assertGroupDoesNotExist(name);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id(name).get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id(name).get());
   }
 
   @Test
@@ -626,14 +626,15 @@
     assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(adminUUID);
 
     // set non existing owner
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(name).owner("Non-Existing Group");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id(name).owner("Non-Existing Group"));
   }
 
   @Test
   public void listNonExistingGroupIncludes_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").includedGroups();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.groups().id("non-existing").includedGroups());
   }
 
   @Test
@@ -645,8 +646,9 @@
   @Test
   public void includeNonExistingGroup() throws Exception {
     AccountGroup.UUID gx = groupOperations.newGroup().create();
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(gx.get()).addGroups("non-existing");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.groups().id(gx.get()).addGroups("non-existing"));
   }
 
   @Test
@@ -675,8 +677,7 @@
 
   @Test
   public void listNonExistingGroupMembers_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").members();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id("non-existing").members());
   }
 
   @Test
@@ -806,13 +807,13 @@
 
     // By UUID
     List<GroupInfo> owned = gApi.groups().list().withOwnedBy(parent.get()).get();
-    assertThat(owned.stream().map(g -> new AccountGroup.UUID(g.id)).collect(toList()))
+    assertThat(owned.stream().map(g -> AccountGroup.uuid(g.id)).collect(toList()))
         .containsExactlyElementsIn(children);
 
     // By name
     String parentName = groupOperations.group(parent).get().name();
     owned = gApi.groups().list().withOwnedBy(parentName).get();
-    assertThat(owned.stream().map(g -> new AccountGroup.UUID(g.id)).collect(toList()))
+    assertThat(owned.stream().map(g -> AccountGroup.uuid(g.id)).collect(toList()))
         .containsExactlyElementsIn(children);
 
     // By group that does not own any others
@@ -820,9 +821,11 @@
     assertThat(owned).isEmpty();
 
     // By non-existing group
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Group Not Found: does-not-exist");
-    gApi.groups().list().withOwnedBy("does-not-exist").get();
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.groups().list().withOwnedBy("does-not-exist").get());
+    assertThat(thrown).hasMessageThat().contains("Group Not Found: does-not-exist");
   }
 
   @Test
@@ -984,7 +987,7 @@
   }
 
   private void deleteGroupRef(String groupId) throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(groupId);
+    AccountGroup.UUID uuid = AccountGroup.uuid(groupId);
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefUpdate ru = repo.updateRef(RefNames.refsGroups(uuid));
       ru.setForceUpdate(true);
@@ -1023,9 +1026,9 @@
 
     // user cannot reindex any group
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to index group");
-    gApi.groups().id(group.id).index();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.groups().id(group.id).index());
+    assertThat(thrown).hasMessageThat().contains("not allowed to index group");
   }
 
   @Test
@@ -1037,8 +1040,7 @@
   @Test
   public void pushToDeletedGroupBranchIsRejectedForAllUsersRepo() throws Exception {
     String groupRef =
-        RefNames.refsDeletedGroups(
-            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(allUsers, groupRef);
     assertPushToGroupBranch(allUsers, groupRef, "group update not allowed");
   }
@@ -1054,7 +1056,7 @@
   public void pushToGroupsBranchForNonAllUsersRepo() throws Exception {
     assertCreateGroupBranch(project);
     String groupRef =
-        RefNames.refsGroups(new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(project, groupRef);
     assertPushToGroupBranch(project, groupRef, null);
   }
@@ -1063,8 +1065,7 @@
   public void pushToDeletedGroupsBranchForNonAllUsersRepo() throws Exception {
     assertCreateGroupBranch(project);
     String groupRef =
-        RefNames.refsDeletedGroups(
-            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(project, groupRef);
     assertPushToGroupBranch(project, groupRef, null);
   }
@@ -1120,13 +1121,13 @@
   }
 
   @Test
-  public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Exception {
+  public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Throwable {
     pushToGroupBranchForReviewAndSubmit(
         allUsers, RefNames.refsGroups(adminGroupUuid()), "group update not allowed");
   }
 
   @Test
-  public void pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit() throws Exception {
+  public void pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit() throws Throwable {
     String groupRef = RefNames.refsGroups(adminGroupUuid());
     createBranch(project, groupRef);
     pushToGroupBranchForReviewAndSubmit(project, groupRef, null);
@@ -1160,14 +1161,14 @@
   @Test
   public void cannotCreateGroupBranch() throws Exception {
     testCannotCreateGroupBranch(
-        RefNames.REFS_GROUPS + "*", RefNames.refsGroups(new AccountGroup.UUID(name("foo"))));
+        RefNames.REFS_GROUPS + "*", RefNames.refsGroups(AccountGroup.uuid(name("foo"))));
   }
 
   @Test
   public void cannotCreateDeletedGroupBranch() throws Exception {
     testCannotCreateGroupBranch(
         RefNames.REFS_DELETED_GROUPS + "*",
-        RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo"))));
+        RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo"))));
   }
 
   @Test
@@ -1217,7 +1218,7 @@
 
   @Test
   public void cannotDeleteDeletedGroupBranch() throws Exception {
-    String groupRef = RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo")));
+    String groupRef = RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo")));
     createBranch(allUsers, groupRef);
     testCannotDeleteGroupBranch(RefNames.REFS_DELETED_GROUPS + "*", groupRef);
   }
@@ -1255,7 +1256,7 @@
   public void stalenessChecker() throws Exception {
     // Newly created group is not stale
     GroupInfo groupInfo = gApi.groups().create(name("foo")).get();
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupInfo.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(groupInfo.id);
     assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
 
     // Manual update makes index document stale
@@ -1336,12 +1337,12 @@
       // Create a group without updating the cache or index,
       // then run the reindexer -> only the new group is reindexed.
       String groupName = "foo";
-      AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupName + "-UUID");
+      AccountGroup.UUID groupUuid = AccountGroup.uuid(groupName + "-UUID");
       groupsUpdate.createGroupInNoteDb(
           InternalGroupCreation.builder()
               .setGroupUUID(groupUuid)
-              .setNameKey(new AccountGroup.NameKey(groupName))
-              .setId(new AccountGroup.Id(seq.nextGroupId()))
+              .setNameKey(AccountGroup.nameKey(groupName))
+              .setId(AccountGroup.id(seq.nextGroupId()))
               .build(),
           InternalGroupUpdate.builder().build());
       slaveGroupIndexer.run();
@@ -1395,12 +1396,9 @@
 
   private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<AccountInfo, String>() {
-          @Override
-          public boolean apply(AccountInfo actualAccount, String expectedName) {
-            String username = actualAccount == null ? null : actualAccount.username;
-            return Objects.equals(username, expectedName);
-          }
+        (actualAccount, expectedName) -> {
+          String username = actualAccount == null ? null : actualAccount.username;
+          return Objects.equals(username, expectedName);
         },
         "has username");
   }
@@ -1416,9 +1414,8 @@
   }
 
   private void pushToGroupBranchForReviewAndSubmit(
-      Project.NameKey project, String groupRef, String expectedError) throws Exception {
-    grantLabel(
-        "Code-Review", -2, 2, project, RefNames.REFS_GROUPS + "*", false, REGISTERED_USERS, false);
+      Project.NameKey project, String groupRef, String expectedError) throws Throwable {
+    grantLabel("Code-Review", -2, 2, project, RefNames.REFS_GROUPS + "*", REGISTERED_USERS, false);
     grant(project, RefNames.REFS_GROUPS + "*", Permission.SUBMIT, false, REGISTERED_USERS);
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
@@ -1430,14 +1427,16 @@
             .create(admin.newIdent(), repo, "Update group config", "group.config", "some content")
             .to(MagicBranch.NEW_CHANGE + groupRef);
     r.assertOkStatus();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(groupRef);
+    assertThat(r.getChange().change().getDest().branch()).isEqualTo(groupRef);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
 
+    ThrowingRunnable submit = () -> gApi.changes().id(r.getChangeId()).current().submit();
     if (expectedError != null) {
-      exception.expect(ResourceConflictException.class);
-      exception.expectMessage("group update not allowed");
+      Throwable thrown = assertThrows(ResourceConflictException.class, submit);
+      assertThat(thrown).hasMessageThat().contains("group update not allowed");
+    } else {
+      submit.run();
     }
-    gApi.changes().id(r.getChangeId()).current().submit();
   }
 
   private void createBranch(Project.NameKey project, String ref) throws IOException {
@@ -1553,7 +1552,7 @@
 
     void assertReindexOf(List<AccountGroup.UUID> groupUuids) {
       for (AccountGroup.UUID groupUuid : groupUuids) {
-        assertThat(getCount(groupUuid)).named(groupUuid.get()).isEqualTo(1);
+        assertWithMessage(groupUuid.get()).that(getCount(groupUuid)).isEqualTo(1);
       }
       assertThat(countsByGroup).hasSize(groupUuids.size());
       clear();
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index 5e143c0..73731e5 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
@@ -36,12 +37,9 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class GroupsUpdateIT {
   @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
   @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
   @Inject private Groups groups;
 
@@ -65,11 +63,11 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("contributors"))
+            .setName(AccountGroup.nameKey("contributors"))
             .setMemberModification(
                 new CreateAnotherGroupOnceAsSideEffectOfMemberModification("verifiers"))
             .build();
-    updateGroup(new AccountGroup.UUID("users-UUID"), groupUpdate);
+    updateGroup(AccountGroup.uuid("users-UUID"), groupUpdate);
 
     Stream<String> allGroupNames = getAllGroupNames();
     assertThat(allGroupNames).containsAllOf("contributors", "verifiers");
@@ -79,9 +77,9 @@
   public void groupUpdateFailsWithExceptionForNotExistingGroup() throws Exception {
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription("A description for the group").build();
-
-    expectedException.expect(NoSuchGroupException.class);
-    updateGroup(new AccountGroup.UUID("nonexistent-group-UUID"), groupUpdate);
+    assertThrows(
+        NoSuchGroupException.class,
+        () -> updateGroup(AccountGroup.uuid("nonexistent-group-UUID"), groupUpdate));
   }
 
   private void createGroup(String groupName, String groupUuid) throws Exception {
@@ -107,9 +105,9 @@
 
   private static InternalGroupCreation getGroupCreation(String groupName, String groupUuid) {
     return InternalGroupCreation.builder()
-        .setGroupUUID(new AccountGroup.UUID(groupUuid))
-        .setNameKey(new AccountGroup.NameKey(groupName))
-        .setId(new AccountGroup.Id(Math.abs(groupName.hashCode())))
+        .setGroupUUID(AccountGroup.uuid(groupUuid))
+        .setNameKey(AccountGroup.nameKey(groupName))
+        .setId(AccountGroup.id(Math.abs(groupName.hashCode())))
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 1d3eb17..2943445 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.plugin;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -136,15 +137,16 @@
 
   @Test
   public void installNotAllowed() throws Exception {
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("remote plugin administration is disabled");
-    gApi.plugins().install("test.js", new InstallPluginInput());
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.plugins().install("test.js", new InstallPluginInput()));
+    assertThat(thrown).hasMessageThat().contains("remote plugin administration is disabled");
   }
 
   @Test
   public void getNonExistingThrowsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.plugins().name("does-not-exist");
+    assertThrows(ResourceNotFoundException.class, () -> gApi.plugins().name("does-not-exist"));
   }
 
   private ListRequest list() throws RestApiException {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 8cdd2f66..4bc4037 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -89,9 +90,11 @@
 
   @Test
   public void emptyInput() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input requires 'account'");
-    gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput()));
+    assertThat(thrown).hasMessageThat().contains("input requires 'account'");
   }
 
   @Test
@@ -101,9 +104,11 @@
     in.permission = "notapermission";
     in.ref = "refs/heads/master";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("not recognized");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("not recognized");
   }
 
   @Test
@@ -112,9 +117,11 @@
     in.account = user.email();
     in.permission = "forge_author";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("must set 'ref'");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("must set 'ref'");
   }
 
   @Test
@@ -124,9 +131,11 @@
     in.permission = "rebase";
     in.ref = "refs/heads/master";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("recognized as ref permission");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("recognized as ref permission");
   }
 
   @Test
@@ -136,9 +145,11 @@
     in.permission = "rebase";
     in.ref = "refs/heads/master";
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account 'doesnotexist@invalid.com' not found");
-    gApi.projects().name(normalProject.get()).checkAccess(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(normalProject.get()).checkAccess(in));
+    assertThat(thrown).hasMessageThat().contains("Account 'doesnotexist@invalid.com' not found");
   }
 
   private static class TestCase {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
index 388ea30..96ba722 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
@@ -50,7 +51,7 @@
   @Test
   public void noProblem() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
@@ -115,7 +116,7 @@
   @Test
   public void detectAutoCloseableChangeByChangeId() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -138,7 +139,7 @@
   @Test
   public void fixAutoCloseableChangeByChangeId() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -162,7 +163,7 @@
   @Test
   public void maxCommits() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -196,7 +197,7 @@
   @Test
   public void skipCommits() throws Exception {
     PushOneCommit.Result r = createChange("refs/for/master");
-    String branch = r.getChange().change().getDest().get();
+    String branch = r.getChange().change().getDest().branch();
 
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
@@ -232,18 +233,21 @@
     CheckProjectInput input = new CheckProjectInput();
     input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branch is required");
-    gApi.projects().name(project.get()).check(input);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown).hasMessageThat().contains("branch is required");
   }
 
   @Test
   public void nonExistingBranch() throws Exception {
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck("non-existing");
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("branch 'non-existing' not found");
-    gApi.projects().name(project.get()).check(input);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown).hasMessageThat().contains("branch 'non-existing' not found");
   }
 
   @Test
@@ -266,11 +270,14 @@
     input.autoCloseableChangesCheck.maxCommits =
         ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT + 1;
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "max commits can at most be set to "
-            + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
-    gApi.projects().name(project.get()).check(input);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(project.get()).check(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "max commits can at most be set to "
+                + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
   }
 
   private RevCommit pushCommitWithoutChangeIdForReview() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
index 4b5fe1e..54aa192 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
@@ -24,7 +24,7 @@
 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 com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -54,7 +54,7 @@
 
     assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
 
-    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+    createBranch(BranchNameKey.create(project, "test-branch"));
 
     assertThat(getIncludedIn(result.getCommit().getId()).branches)
         .containsExactly("master", "test-branch");
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index e51a069..4a5ad6a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -47,14 +48,12 @@
 
   @Test
   public void defaultDashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
+    assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
   }
 
   @Test
   public void dashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().dashboard("my:dashboard").get();
+    assertThrows(ResourceNotFoundException.class, () -> project().dashboard("my:dashboard").get());
   }
 
   @Test
@@ -110,8 +109,7 @@
     project().removeDefaultDashboard();
     assertThat(project().dashboard(info.id).get().isDefault).isNull();
 
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
+    assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
   }
 
   @Test
@@ -133,9 +131,9 @@
   @Test
   public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
     DashboardInfo info = createTestDashboard();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("inherited flag can only be used with default");
-    project().dashboard(info.id).get(true);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().dashboard(info.id).get(true));
+    assertThat(thrown).hasMessageThat().contains("inherited flag can only be used with default");
   }
 
   private void assertDashboardInfo(DashboardInfo actual, DashboardInfo expected) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 77350b0..a516468 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -20,6 +20,7 @@
 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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
@@ -165,17 +166,17 @@
   public void createProjectWithMismatchedInput() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("foo");
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name must match input.name");
-    gApi.projects().name("bar").create(in);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().name("bar").create(in));
+    assertThat(thrown).hasMessageThat().contains("name must match input.name");
   }
 
   @Test
   public void createProjectNoNameInInput() throws Exception {
     ProjectInput in = new ProjectInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input.name is required");
-    gApi.projects().create(in);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("input.name is required");
   }
 
   @Test
@@ -183,9 +184,9 @@
     ProjectInput in = new ProjectInput();
     in.name = name("baz");
     gApi.projects().create(in);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Project already exists");
-    gApi.projects().create(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project already exists");
   }
 
   @Test
@@ -194,9 +195,9 @@
     in.name = name("baz");
     in.parent = "non-existing";
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Project Not Found: " + in.parent);
-    gApi.projects().create(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project Not Found: " + in.parent);
   }
 
   @Test
@@ -205,9 +206,9 @@
     in.name = name("baz");
     in.parent = in.name;
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Project Not Found: " + in.parent);
-    gApi.projects().create(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasMessageThat().contains("Project Not Found: " + in.parent);
   }
 
   @Test
@@ -215,9 +216,11 @@
     ProjectInput in = new ProjectInput();
     in.name = name("foo");
     in.parent = allUsers.get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
-    gApi.projects().create(in);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot inherit from '%s' project", allUsers.get()));
   }
 
   @Test
@@ -354,9 +357,9 @@
   public void nonOwnerCannotSetConfig() throws Exception {
     ConfigInput input = createTestConfigInput();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("write refs/meta/config not permitted");
-    gApi.projects().name(project.get()).config(input);
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).config(input));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
 
   @Test
@@ -372,8 +375,9 @@
 
   @Test
   public void setHeadToNonexistentBranch() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    gApi.projects().name(project.get()).head("does-not-exist");
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.projects().name(project.get()).head("does-not-exist"));
   }
 
   @Test
@@ -389,9 +393,9 @@
   public void setHeadNotAllowed() throws Exception {
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: set HEAD on refs/heads/test");
-    gApi.projects().name(project.get()).head("test");
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).head("test"));
+    assertThat(thrown).hasMessageThat().contains("not permitted: set HEAD on refs/heads/test");
   }
 
   @Test
@@ -614,9 +618,9 @@
 
   @Test
   public void invalidMaxObjectSizeIsRejected() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("100 foo");
-    setMaxObjectSize("100 foo");
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> setMaxObjectSize("100 foo"));
+    assertThat(thrown).hasMessageThat().contains("100 foo");
   }
 
   @Test
@@ -721,7 +725,7 @@
 
   @Nullable
   protected RevCommit getRemoteHead(String project, String branch) throws Exception {
-    return getRemoteHead(new Project.NameKey(project), branch);
+    return getRemoteHead(Project.nameKey(project), branch);
   }
 
   boolean hasHead(Project.NameKey k, String b) {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 3c1428d..b2da402 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -42,8 +43,7 @@
   public void setParentNotAllowed() throws Exception {
     String parent = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(project.get()).parent(parent);
+    assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).parent(parent));
   }
 
   @Test
@@ -51,8 +51,7 @@
   public void setParentNotAllowedForNonOwners() throws Exception {
     String parent = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(project.get()).parent(parent);
+    assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).parent(parent));
   }
 
   @Test
@@ -96,47 +95,63 @@
 
   @Test
   public void setParentForAllProjectsNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot set parent of " + AllProjectsNameProvider.DEFAULT);
-    gApi.projects().name(allProjects.get()).parent(project.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).parent(project.get()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("cannot set parent of " + AllProjectsNameProvider.DEFAULT);
   }
 
   @Test
   public void setParentToSelfNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot set parent to self");
-    gApi.projects().name(project.get()).parent(project.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(project.get()));
+    assertThat(thrown).hasMessageThat().contains("cannot set parent to self");
   }
 
   @Test
   public void setParentToOwnChildNotAllowed() throws Exception {
     String child = projectOperations.newProject().parent(project).create().get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cycle exists between");
-    gApi.projects().name(project.get()).parent(child);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(child));
+    assertThat(thrown).hasMessageThat().contains("cycle exists between");
   }
 
   @Test
   public void setParentToGrandchildNotAllowed() throws Exception {
     Project.NameKey child = projectOperations.newProject().parent(project).create();
     String grandchild = projectOperations.newProject().parent(child).create().get();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cycle exists between");
-    gApi.projects().name(project.get()).parent(grandchild);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(grandchild));
+    assertThat(thrown).hasMessageThat().contains("cycle exists between");
   }
 
   @Test
   public void setParentToNonexistentProject() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    gApi.projects().name(project.get()).parent("non-existing");
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).parent("non-existing"));
+    assertThat(thrown).hasMessageThat().contains("not found");
   }
 
   @Test
   public void setParentToAllUsersNotAllowed() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("Cannot inherit from '%s' project", allUsers.get()));
-    gApi.projects().name(project.get()).parent(allUsers.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).parent(allUsers.get()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Cannot inherit from '%s' project", allUsers.get()));
   }
 
   @Test
@@ -145,8 +160,9 @@
 
     String parent = projectOperations.newProject().create().get();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("All-Users must inherit from All-Projects");
-    gApi.projects().name(allUsers.get()).parent(parent);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allUsers.get()).parent(parent));
+    assertThat(thrown).hasMessageThat().contains("All-Users must inherit from All-Projects");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index cbfc09f..a8a19ac 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
@@ -2481,7 +2482,7 @@
 
       RevCommit parentCommit = c.getParents()[0];
       String parentCommitId =
-          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
+          abbreviateName(parentCommit, 8, testRepo.getRevWalk().getObjectReader());
       headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
 
       SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index fc65eca..bd5a1f7 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -23,9 +23,11 @@
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 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 com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
@@ -83,7 +85,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.change.RevisionResource;
@@ -259,9 +261,11 @@
     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);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision(r).review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot reduce vote on labels for closed change: Code-Review");
   }
 
   @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
@@ -277,28 +281,36 @@
     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();
+    assertThat(psa.patchSetId().get()).isEqualTo(2);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo(2);
+    assertThat(psa.postSubmit()).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());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject()));
+    assertThat(thrown).hasMessageThat().contains("change is closed");
   }
 
   @Test
   public void voteNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("is restricted");
-    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChange().getId().get())
+                    .current()
+                    .review(ReviewInput.approve()));
+    assertThat(thrown).hasMessageThat().contains("is restricted");
   }
 
   @Test
@@ -454,9 +466,11 @@
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: identical tree");
-    orig.revision(r.getCommit().name()).cherryPick(in);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> orig.revision(r.getCommit().name()).cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: identical tree");
   }
 
   @Test
@@ -480,9 +494,11 @@
     ChangeApi orig = gApi.changes().id(triplet);
     assertThat(orig.get().messages).hasSize(1);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: merge conflict");
-    orig.revision(r.getCommit().name()).cherryPick(in);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> orig.revision(r.getCommit().name()).cherryPick(in));
+    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: merge conflict");
   }
 
   @Test
@@ -549,8 +565,8 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String destSha1 = getRemoteHead(project, destBranch).abbreviate(6).name();
-    String changeSha1 = r.getCommit().abbreviate(6).name();
+    String destSha1 = abbreviateName(getRemoteHead(project, destBranch), 6);
+    String changeSha1 = abbreviateName(r.getCommit(), 6);
     assertThat(fileContent)
         .isEqualTo(
             "<<<<<<< HEAD   ("
@@ -627,7 +643,7 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
@@ -654,7 +670,7 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(project, cherryPickBranchName));
 
     CherryPickInput cherryPickInput = new CherryPickInput();
     cherryPickInput.destination = cherryPickBranchName;
@@ -682,17 +698,24 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(mergeChangeResult.getChangeId())
+                    .current()
+                    .cherryPick(cherryPickInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
   }
 
   @Test
@@ -703,24 +726,31 @@
         createCherryPickableMerge(parent1FileName, parent2FileName);
 
     String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+    createBranch(BranchNameKey.create(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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(mergeChangeResult.getChangeId())
+                    .current()
+                    .cherryPick(cherryPickInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
   }
 
   @Test
   public void cherryPickNotify() throws Exception {
-    createBranch(new Branch.NameKey(project, "branch-1"));
-    createBranch(new Branch.NameKey(project, "branch-2"));
-    createBranch(new Branch.NameKey(project, "branch-3"));
+    createBranch(BranchNameKey.create(project, "branch-1"));
+    createBranch(BranchNameKey.create(project, "branch-2"));
+    createBranch(BranchNameKey.create(project, "branch-3"));
 
     // Creates a change for 'admin'.
     PushOneCommit.Result result = createChange();
@@ -759,7 +789,7 @@
 
   @Test
   public void cherryPickKeepReviewers() throws Exception {
-    createBranch(new Branch.NameKey(project, "stable"));
+    createBranch(BranchNameKey.create(project, "stable"));
 
     // Change is created by 'admin'.
     PushOneCommit.Result r = createChange();
@@ -793,7 +823,7 @@
 
   @Test
   public void cherryPickToMergedChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -817,7 +847,7 @@
 
   @Test
   public void cherryPickToOpenChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -835,7 +865,7 @@
 
   @Test
   public void cherryPickToNonVisibleChangeFails() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
     dstChange.assertOkStatus();
@@ -850,10 +880,13 @@
     input.message = srcChange.getCommit().getFullMessage();
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(
-        String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
-    gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
   }
 
   @Test
@@ -867,12 +900,16 @@
     input.base = change2.getCommit().name();
     input.message = change1.getCommit().getFullMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "Change %s with commit %s is abandoned",
-            change2.getChange().getId().get(), input.base));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "Change %s with commit %s is abandoned",
+                change2.getChange().getId().get(), input.base));
   }
 
   @Test
@@ -884,9 +921,13 @@
     input.base = "invalid-sha1";
     input.message = change1.getCommit().getFullMessage();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(String.format("Base %s doesn't represent a valid SHA-1", input.base));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("Base %s doesn't represent a valid SHA-1", input.base));
   }
 
   @Test
@@ -1131,9 +1172,15 @@
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit description not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().name())
+                    .description("test"));
+    assertThat(thrown).hasMessageThat().contains("edit description not permitted");
   }
 
   @Test
@@ -1316,8 +1363,8 @@
 
   @Test
   public void commentOnNonExistingFile() throws Exception {
-    PushOneCommit.Result r = createChange();
-    r = updateChange(r, "new content");
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = updateChange(r1, "new content");
     CommentInput in = new CommentInput();
     in.line = 1;
     in.message = "nit: trailing whitespace";
@@ -1328,10 +1375,14 @@
     reviewInput.comments = comments;
     reviewInput.message = "comment test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format("not found in revision %d,1", r.getChange().change().getId().id));
-    gApi.changes().id(r.getChangeId()).revision(1).review(reviewInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(1).review(reviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("not found in revision %d,1", r2.getChange().change().getId().get()));
   }
 
   @Test
@@ -1359,9 +1410,11 @@
     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");
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> changeApi.revision(r.getCommit().name()).patch("nonexistent-file"));
+    assertThat(thrown).hasMessageThat().contains("File not found: nonexistent-file.");
   }
 
   @Test
@@ -1409,13 +1462,16 @@
 
     // check if it's blocked to delete a vote on a non-current patch set.
     requestScopeOperations.setApiUser(admin.id());
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot access on non-current patch set");
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().getName())
-        .reviewer(user.id().toString())
-        .deleteVote("Code-Review");
+    MethodNotAllowedException thrown =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().getName())
+                    .reviewer(user.id().toString())
+                    .deleteVote("Code-Review"));
+    assertThat(thrown).hasMessageThat().contains("Cannot access on non-current patch set");
   }
 
   @Test
@@ -1525,9 +1581,9 @@
     RevCommit initialCommit = getHead(repo(), "HEAD");
 
     String branchAName = "branchA";
-    createBranch(new Branch.NameKey(project, branchAName));
+    createBranch(BranchNameKey.create(project, branchAName));
     String branchBName = "branchB";
-    createBranch(new Branch.NameKey(project, branchBName));
+    createBranch(BranchNameKey.create(project, branchBName));
 
     PushOneCommit.Result changeAResult =
         pushFactory
@@ -1562,6 +1618,6 @@
   }
 
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index ba228f6..62a7037 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -164,9 +165,10 @@
     int sizeOfRest = 451;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
@@ -187,9 +189,10 @@
     int sizeLimit = 10 * 1024;
     fixReplacementInfo.replacement = getStringFor(sizeLimit);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
@@ -254,12 +257,15 @@
   public void descriptionOfFixSuggestionIsMandatory() throws Exception {
     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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A description is required for the suggested fix of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -278,13 +284,16 @@
   public void fixReplacementsAreMandatory() throws Exception {
     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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "At least one replacement is required"
+                    + " for the suggested fix of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -305,12 +314,15 @@
   public void pathOfFixReplacementIsMandatory() throws Exception {
     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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A file path must be given for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -331,20 +343,24 @@
   public void rangeOfFixReplacementIsMandatory() throws Exception {
     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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A range must be given for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
   public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
     fixReplacementInfo.range = createRange(13, 9, 5, 10);
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Range (13:9 - 5:10)");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("Range (13:9 - 5:10)");
   }
 
   @Test
@@ -364,9 +380,10 @@
         createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
     withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("overlap");
-    addRobotComment(changeId, withFixRobotCommentInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown).hasMessageThat().contains("overlap");
   }
 
   @Test
@@ -461,13 +478,16 @@
   public void replacementStringOfFixReplacementIsMandatory() throws Exception {
     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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A content for replacement must be "
+                    + "indicated for the replacement of the robot comment on %s",
+                withFixRobotCommentInput.path));
   }
 
   @Test
@@ -602,9 +622,11 @@
 
     List<String> fixIds = getFixIds(robotCommentInfos);
     gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("merge");
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixIds.get(1)));
+    assertThat(thrown).hasMessageThat().contains("merge");
   }
 
   @Test
@@ -708,8 +730,9 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(fixId);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(fixId));
   }
 
   @Test
@@ -728,9 +751,11 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("current");
-    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("current");
   }
 
   @Test
@@ -783,9 +808,11 @@
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("based");
-    gApi.changes().id(changeId).current().applyFix(fixId);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("based");
   }
 
   @Test
@@ -845,8 +872,9 @@
     String fixId = Iterables.getOnlyElement(fixIds);
     String nonExistentFixId = fixId + "_non-existent";
 
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(nonExistentFixId));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 6827219..28d94d3 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
@@ -187,7 +188,7 @@
     adminRestSession.post(urlPublish(changeId)).assertNoContent();
     assertThat(getEdit(changeId)).isAbsent();
     PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
-    assertThat(newCurrentPatchSet.getId()).isNotEqualTo(oldCurrentPatchSet.getId());
+    assertThat(newCurrentPatchSet.id()).isNotEqualTo(oldCurrentPatchSet.id());
     assertChangeMessages(
         changeId,
         ImmutableList.of(
@@ -240,13 +241,13 @@
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     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().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
   }
 
@@ -259,13 +260,13 @@
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     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().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
   }
 
@@ -275,7 +276,7 @@
     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());
+    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -319,9 +320,13 @@
     createEmptyEditFor(changeId);
     String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("New commit message cannot be same as existing commit message");
-    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("New commit message cannot be same as existing commit message");
   }
 
   @Test
@@ -329,9 +334,13 @@
     createEmptyEditFor(changeId);
     String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    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");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("New commit message cannot be same as existing commit message");
   }
 
   @Test
@@ -382,7 +391,7 @@
     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.commitId().name()));
       assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
     }
 
@@ -576,9 +585,15 @@
   @Test
   public void writeNoChanges() throws Exception {
     createEmptyEditFor(changeId);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("no changes were made");
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .edit()
+                    .modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD)));
+    assertThat(thrown).hasMessageThat().contains("no changes were made");
   }
 
   @Test
@@ -686,8 +701,7 @@
     r1.assertOkStatus();
 
     // Try to create edit as admin
-    exception.expect(AuthException.class);
-    createEmptyEditFor(r1.getChangeId());
+    assertThrows(AuthException.class, () -> createEmptyEditFor(r1.getChangeId()));
   }
 
   @Test
@@ -696,9 +710,11 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     gApi.changes().id(changeId).current().submit();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("change %s is merged", change._number));
-    createArbitraryEditFor(changeId);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> createArbitraryEditFor(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("change %s is merged", change._number));
   }
 
   @Test
@@ -706,9 +722,11 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     gApi.changes().id(changeId).abandon();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(String.format("change %s is abandoned", change._number));
-    createArbitraryEditFor(changeId);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> createArbitraryEditFor(changeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("change %s is abandoned", change._number));
   }
 
   private void createArbitraryEditFor(String changeId) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 899b09a..bfbe3a3 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
@@ -78,6 +79,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.testing.EditInfoSubject;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
@@ -86,7 +88,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
@@ -110,6 +111,7 @@
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -842,7 +844,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
 
     // Admin user trying to move from WIP to ready should succeed.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(testRepo, r.getPatchSet().refName() + ":ps");
     testRepo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user, testRepo);
     r.assertOkStatus();
@@ -858,7 +860,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
     // Admin user trying to move from ready to WIP should succeed.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(testRepo, r.getPatchSet().refName() + ":ps");
     testRepo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
     r.assertOkStatus();
@@ -872,7 +874,7 @@
     grant(
         project, "refs/*", Permission.FORGE_COMMITTER, false, SystemGroupBackend.REGISTERED_USERS);
     TestRepository<?> user2Repo = cloneProject(project, user2);
-    GitUtil.fetch(user2Repo, r.getPatchSet().getRefName() + ":ps");
+    GitUtil.fetch(user2Repo, r.getPatchSet().refName() + ":ps");
     user2Repo.reset("ps");
     r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
     r.assertErrorStatus(ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
@@ -1403,7 +1405,7 @@
 
     // create a second change as user (depends on the change from admin)
     TestRepository<?> userRepo = cloneProject(project, user);
-    GitUtil.fetch(userRepo, r.getPatchSet().getRefName() + ":change");
+    GitUtil.fetch(userRepo, r.getPatchSet().refName() + ":change");
     userRepo.reset("change");
     push =
         pushFactory.create(
@@ -1513,8 +1515,8 @@
 
     // Check that a change was created for each.
     for (RevCommit c : commits) {
-      assertThat(byCommit(c).change().getSubject())
-          .named("change for " + c.name())
+      assertWithMessage("change for " + c.name())
+          .that(byCommit(c).change().getSubject())
           .isEqualTo(c.getShortMessage());
     }
 
@@ -1526,9 +1528,9 @@
       RevCommit c2 = commits2.get(i);
       String name = "change for " + c2.name();
       ChangeData cd = byCommit(c);
-      assertThat(cd.change().getSubject()).named(name).isEqualTo(c2.getShortMessage());
-      assertThat(getPatchSetRevisions(cd))
-          .named(name)
+      assertWithMessage(name).that(cd.change().getSubject()).isEqualTo(c2.getShortMessage());
+      assertWithMessage(name)
+          .that(getPatchSetRevisions(cd))
           .containsExactlyEntriesIn(ImmutableMap.of(1, c.name(), 2, c2.name()));
     }
 
@@ -1591,8 +1593,7 @@
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(ref);
     assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
     String reason =
-        String.format(
-            "commit %s: missing Change-Id in message footer", c.toObjectId().abbreviate(7).name());
+        String.format("commit %s: missing Change-Id in message footer", abbreviateName(c));
     assertThat(refUpdate.getMessage()).isEqualTo(reason);
 
     assertThat(r.getMessages()).contains("\nERROR: " + reason);
@@ -1617,8 +1618,7 @@
     r.assertErrorStatus(
         String.format(
             "commit %s: %s",
-            r.getCommit().abbreviate(RevId.ABBREV_LEN).name(),
-            ChangeIdValidator.CHANGE_ID_MISMATCH_MSG));
+            abbreviateName(r.getCommit()), ChangeIdValidator.CHANGE_ID_MISMATCH_MSG));
   }
 
   @Test
@@ -1802,7 +1802,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()).commitId().name();
 
     String r = "refs/changes/" + id;
     assertPushOk(pushHead(testRepo, r, false), r);
@@ -1820,7 +1820,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()).commitId().name();
 
     String r = "refs/for/master";
     assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
@@ -2351,9 +2351,9 @@
     assertThat(pr.getMessages())
         .contains(
             "warning: no changes between prior commit "
-                + c.abbreviate(7).name()
+                + abbreviateName(c)
                 + " and new commit "
-                + amended.abbreviate(7).name());
+                + abbreviateName(amended));
   }
 
   @Test
@@ -2379,8 +2379,7 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains(
-            "warning: " + amended.abbreviate(7).name() + ": no files changed, message updated");
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, message updated");
   }
 
   @Test
@@ -2404,8 +2403,7 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains(
-            "warning: " + amended.abbreviate(7).name() + ": no files changed, author changed");
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, author changed");
   }
 
   @Test
@@ -2432,7 +2430,7 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains("warning: " + amended.abbreviate(7).name() + ": no files changed, was rebased");
+        .contains("warning: " + abbreviateName(amended) + ": no files changed, was rebased");
   }
 
   @Test
@@ -2547,11 +2545,11 @@
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.id());
+        assertWithMessage(name).that(cd.reviewers().all()).containsExactly(expectedReviewer.id());
         // Remove reviewer from PS1 so we can test adding this same reviewer on PS2 below.
         gApi.changes().id(cd.getId().get()).reviewer(expectedReviewer.id().toString()).remove();
       }
-      assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+      assertWithMessage(name).that(byCommit(c).reviewers().all()).isEmpty();
     }
 
     List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
@@ -2560,9 +2558,9 @@
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.id());
+        assertWithMessage(name).that(cd.reviewers().all()).containsExactly(expectedReviewer.id());
       } else {
-        assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+        assertWithMessage(name).that(byCommit(c).reviewers().all()).isEmpty();
       }
     }
   }
@@ -2629,20 +2627,20 @@
   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());
+      revisions.put(ps.number(), ps.commitId().name());
     }
     return revisions;
   }
 
   private ChangeData byCommit(ObjectId id) throws Exception {
     List<ChangeData> cds = queryProvider.get().byCommit(id);
-    assertThat(cds).named("change for " + id.name()).hasSize(1);
+    assertWithMessage("change for " + id.name()).that(cds).hasSize(1);
     return cds.get(0);
   }
 
   private ChangeData byChangeId(Change.Id id) throws Exception {
     List<ChangeData> cds = queryProvider.get().byLegacyChangeId(id);
-    assertThat(cds).named("change " + id).hasSize(1);
+    assertWithMessage("change " + id).that(cds).hasSize(1);
     return cds.get(0);
   }
 
@@ -2695,4 +2693,8 @@
         ? infos.stream().map(a -> a.email).collect(toImmutableList())
         : ImmutableList.of();
   }
+
+  private String abbreviateName(AnyObjectId id) throws Exception {
+    return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index d1349d0..fc2e5cb 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
@@ -472,7 +473,7 @@
         ObjectInserter ins = serverRepo.newObjectInserter();
         RevWalk rw = new RevWalk(serverRepo)) {
       Ref ref = serverRepo.exactRef(refName);
-      assertThat(ref).named(refName).isNotNull();
+      assertWithMessage(refName).that(ref).isNotNull();
       ObjectId oldCommitId = ref.getObjectId();
 
       DirCache dc = DirCache.newInCore();
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index cabb1ba..96710e2 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -7,6 +7,7 @@
     deps = [
         ":push_for_review",
         ":submodule_util",
+        "//java/com/google/gerrit/git",
         "//lib/commons:lang",
     ],
 ) for f in glob(["*IT.java"])]
@@ -17,6 +18,7 @@
     srcs = ["AbstractPushForReview.java"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/mail",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
index eb4845e..b895ddf 100644
--- a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -82,7 +82,7 @@
 
   @Test
   public void deleteNotAllowedWithOnlyPushPermission() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, false);
+    grant(project, "refs/*", Permission.PUSH);
     assertDeleteRef(REJECTED_OTHER_REASON);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
index ac0cbd8..e2aa666 100644
--- a/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.junit.TestRepository;
@@ -50,8 +53,15 @@
         .add(".gitmodules", config.toText())
         .create();
 
-    exception.expectMessage(expectedErrorMessage);
-    exception.expect(TransportException.class);
-    repo.git().push().setRemote("origin").setRefSpecs(new RefSpec("HEAD:refs/for/master")).call();
+    TransportException thrown =
+        assertThrows(
+            TransportException.class,
+            () ->
+                repo.git()
+                    .push()
+                    .setRemote("origin")
+                    .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
+                    .call());
+    assertThat(thrown).hasMessageThat().contains(expectedErrorMessage);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index 6516b32..7a4a5c5 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -76,8 +77,9 @@
     assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
   }
 
-  private static String implicitMergeOf(ObjectId commit) {
-    return "implicit merge of " + commit.abbreviate(7).name();
+  private String implicitMergeOf(ObjectId commit) throws Exception {
+    return "implicit merge of "
+        + ObjectIds.abbreviateName(commit, testRepo.getRevWalk().getObjectReader());
   }
 
   private void setRejectImplicitMerges() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 0ef3473..f682342 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -273,10 +273,10 @@
     ci.project = project.get();
     ci.branch = "master";
     ci.subject = "A change";
-    Change.Id id = new Change.Id(gApi.changes().create(ci).get()._number);
+    Change.Id id = Change.id(gApi.changes().create(ci).get()._number);
 
     requestScopeOperations.setApiUser(admin.id());
-    ObjectId ps1Id = forceFetch(new PatchSet.Id(id, 1).toRefName());
+    ObjectId ps1Id = forceFetch(PatchSet.id(id, 1).toRefName());
     ObjectId ps2Id = testRepo.amend(ps1Id).add("file", "content").create();
     PushResult r = push(ps2Id.name() + ":refs/for/master");
     assertThat(r)
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 02549a7..4e37a7c 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -22,7 +22,6 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 
-import com.google.common.base.Predicates;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -145,25 +144,25 @@
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master%submit");
     mr.assertOkStatus();
     cd1 = mr.getChange();
-    psRef1 = cd1.currentPatchSet().getId().toRefName();
+    psRef1 = cd1.currentPatchSet().id().toRefName();
     metaRef1 = RefNames.changeMetaRef(cd1.getId());
     PushOneCommit.Result br =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch%submit");
     br.assertOkStatus();
     cd2 = br.getChange();
-    psRef2 = cd2.currentPatchSet().getId().toRefName();
+    psRef2 = cd2.currentPatchSet().id().toRefName();
     metaRef2 = RefNames.changeMetaRef(cd2.getId());
 
     // Second 2 changes are unmerged.
     mr = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
     mr.assertOkStatus();
     cd3 = mr.getChange();
-    psRef3 = cd3.currentPatchSet().getId().toRefName();
+    psRef3 = cd3.currentPatchSet().id().toRefName();
     metaRef3 = RefNames.changeMetaRef(cd3.getId());
     br = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch");
     br.assertOkStatus();
     cd4 = br.getChange();
-    psRef4 = cd4.currentPatchSet().getId().toRefName();
+    psRef4 = cd4.currentPatchSet().id().toRefName();
     metaRef4 = RefNames.changeMetaRef(cd4.getId());
 
     try (Repository repo = repoManager.openRepository(project)) {
@@ -384,7 +383,7 @@
   public void uploadPackAllRefsAreVisibleOrphanedTag() throws Exception {
     allow("refs/*", Permission.READ, REGISTERED_USERS);
     // Delete the pending change on 'branch' and 'branch' itself so that the tag gets orphaned
-    gApi.changes().id(cd4.getId().id).delete();
+    gApi.changes().id(cd4.getId().get()).delete();
     gApi.projects().name(project.get()).branch("refs/heads/branch").delete();
 
     requestScopeOperations.setApiUser(user.id());
@@ -442,7 +441,7 @@
       TestRepository<?> tr = new TestRepository<>(repo);
       String subject = "Subject for missing commit";
       Change c = new Change(cd3.change());
-      PatchSet.Id psId = new PatchSet.Id(cd3.getId(), 2);
+      PatchSet.Id psId = PatchSet.id(cd3.getId(), 2);
       c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
 
       PersonIdent committer = serverIdent.get();
@@ -570,7 +569,7 @@
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = cd3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
@@ -587,7 +586,7 @@
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = cd3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
@@ -603,7 +602,7 @@
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      String change3RefName = cd3.currentPatchSet().getRefName();
+      String change3RefName = cd3.currentPatchSet().refName();
       assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(cd3.getId().get()).setPrivate(true, null);
@@ -719,7 +718,7 @@
   }
 
   private List<String> getRefs(Git git) throws Exception {
-    return getRefs(git, Predicates.alwaysTrue());
+    return getRefs(git, x -> true);
   }
 
   private List<String> getUserRefs(Git git) throws Exception {
@@ -773,10 +772,10 @@
   }
 
   private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
-    PatchSet.Id psId = new PatchSet.Id(cd.getId(), psNum);
+    PatchSet.Id psId = 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());
+    return ps.commitId();
   }
 
   private AccountGroup.UUID createSelfOwnedGroup(String name, TestAccount... members)
@@ -792,7 +791,7 @@
     groupInput.ownerId = ownerGroup != null ? ownerGroup.get() : null;
     groupInput.members =
         Arrays.stream(members).map(m -> String.valueOf(m.id().get())).collect(toList());
-    return new AccountGroup.UUID(gApi.groups().create(groupInput).get().id);
+    return AccountGroup.uuid(gApi.groups().create(groupInput).get().id);
   }
 
   private static Map<String, Ref> getAllRefs(Repository repo) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 759a99a..d783c7c 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -156,15 +156,15 @@
     assertCommit(project, "refs/heads/master");
 
     ChangeData cd =
-        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+        Iterables.getOnlyElement(queryProvider.get().byKey(Change.key(r.getChangeId())));
     RevCommit c = r.getCommit();
-    PatchSet.Id psId = cd.currentPatchSet().getId();
+    PatchSet.Id psId = cd.currentPatchSet().id();
     assertThat(psId.get()).isEqualTo(1);
     assertThat(cd.change().isMerged()).isTrue();
     assertSubmitApproval(psId);
 
     assertThat(cd.patchSets()).hasSize(1);
-    assertThat(cd.patchSet(psId).getRevision().get()).isEqualTo(c.name());
+    assertThat(cd.patchSet(psId).commitId()).isEqualTo(c);
   }
 
   @Test
@@ -183,7 +183,7 @@
     pushCommitTo(commit, master);
     assertCommit(project, master);
     ChangeData cd =
-        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+        Iterables.getOnlyElement(queryProvider.get().byKey(Change.key(r.getChangeId())));
     assertThat(cd.change().isMerged()).isTrue();
 
     RemoteRefUpdate.Status status = pushCommitTo(commit, "refs/for/other");
@@ -192,8 +192,8 @@
     pushCommitTo(commit, other);
     assertCommit(project, other);
 
-    for (ChangeData c : queryProvider.get().byKey(new Change.Key(r.getChangeId()))) {
-      if (c.change().getDest().get().equals(other)) {
+    for (ChangeData c : queryProvider.get().byKey(Change.key(r.getChangeId()))) {
+      if (c.change().getDest().branch().equals(other)) {
         assertThat(c.change().isMerged()).isTrue();
       }
     }
@@ -237,8 +237,8 @@
     assertSubmitApproval(psId2);
 
     assertThat(cd.patchSets()).hasSize(2);
-    assertThat(cd.patchSet(psId1).getRevision().get()).isEqualTo(c1.name());
-    assertThat(cd.patchSet(psId2).getRevision().get()).isEqualTo(c2.name());
+    assertThat(cd.patchSet(psId1).commitId()).isEqualTo(c1);
+    assertThat(cd.patchSet(psId2).commitId()).isEqualTo(c2);
   }
 
   @Test
@@ -254,17 +254,17 @@
     r = amendChange(changeId);
     ChangeData cd = r.getChange();
     PatchSet.Id psId2 = cd.change().currentPatchSetId();
-    assertThat(psId2.getParentKey()).isEqualTo(psId1.getParentKey());
+    assertThat(psId2.changeId()).isEqualTo(psId1.changeId());
     assertThat(psId2.get()).isEqualTo(2);
 
     testRepo.reset(c1);
     assertPushOk(pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
 
-    cd = changeDataFactory.create(project, psId1.getParentKey());
+    cd = changeDataFactory.create(project, psId1.changeId());
     Change c = cd.change();
     assertThat(c.isMerged()).isTrue();
     assertThat(c.currentPatchSetId()).isEqualTo(psId1);
-    assertThat(cd.patchSets().stream().map(PatchSet::getId).collect(toList()))
+    assertThat(cd.patchSets().stream().map(PatchSet::id).collect(toList()))
         .containsExactly(psId1, psId2);
   }
 
@@ -304,27 +304,27 @@
     assertThat(cd2.change().isMerged()).isTrue();
     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).commitId()).isEqualTo(c2_1);
+    assertThat(cd2.patchSet(psId2_2).commitId()).isEqualTo(c2_2);
 
     ChangeData cd1 = r1.getChange();
     assertThat(cd1.change().isMerged()).isTrue();
     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).commitId()).isEqualTo(c1_1);
+    assertThat(cd1.patchSet(psId1_2).commitId()).isEqualTo(c1_2);
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId) throws Exception {
-    ChangeNotes notes = notesFactory.createChecked(project, patchSetId.getParentKey()).load();
+    ChangeNotes notes = notesFactory.createChecked(project, patchSetId.changeId()).load();
     return approvalsUtil.getSubmitter(notes, patchSetId);
   }
 
   private void assertSubmitApproval(PatchSet.Id patchSetId) throws Exception {
     PatchSetApproval a = getSubmitter(patchSetId);
     assertThat(a.isLegacySubmit()).isTrue();
-    assertThat(a.getValue()).isEqualTo((short) 1);
-    assertThat(a.getAccountId()).isEqualTo(admin.id());
+    assertThat(a.value()).isEqualTo((short) 1);
+    assertThat(a.accountId()).isEqualTo(admin.id());
   }
 
   private void assertCommit(Project.NameKey project, String branch) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index a04fe3c..2f551a5 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -108,7 +108,7 @@
   public void subscriptionWildcardACLForMissingProject() throws Exception {
 
     allowMatchingSubmoduleSubscription(
-        subKey, "refs/heads/*", new Project.NameKey("not-existing-super-project"), "refs/heads/*");
+        subKey, "refs/heads/*", Project.nameKey("not-existing-super-project"), "refs/heads/*");
     pushChangeTo(subRepo, "master");
   }
 
@@ -379,10 +379,7 @@
   @Test
   public void subscriptionFailOnWrongProjectACL() throws Exception {
     allowMatchingSubmoduleSubscription(
-        subKey,
-        "refs/heads/master",
-        new Project.NameKey("wrong-super-project"),
-        "refs/heads/master");
+        subKey, "refs/heads/master", Project.nameKey("wrong-super-project"), "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
     createSubmoduleSubscription(superRepo, "master", subKey, "master");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index dc84d13..9ebc3de 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -17,14 +17,17 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 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.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.testing.ConfigSuite;
@@ -136,7 +139,7 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
+    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId =
         subRepo
@@ -152,8 +155,8 @@
     // 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:
-    assertThat(preview).containsKey(new Branch.NameKey(superKey, "refs/heads/master"));
-    assertThat(preview).containsKey(new Branch.NameKey(subKey, "refs/heads/master"));
+    assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
+    assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
 
     if ((getSubmitType() == SubmitType.CHERRY_PICK)
         || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
@@ -614,7 +617,7 @@
     expectToHaveSubmoduleState(topRepo, "master", botKey, bottomRepo, "master");
   }
 
-  private String prepareBranchCircularSubscription() throws Exception {
+  private void testBranchCircularSubscription(ThrowingConsumer<String> apiCall) throws Exception {
     Project.NameKey topKey = createProjectForPush(getSubmitType());
     Project.NameKey midKey = createProjectForPush(getSubmitType());
     Project.NameKey botKey = createProjectForPush(getSubmitType());
@@ -634,23 +637,24 @@
     String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
 
     approve(changeId);
-    exception.expectMessage("Branch level circular subscriptions detected");
-    exception.expectMessage(topKey.get() + ",refs/heads/master");
-    exception.expectMessage(midKey.get() + ",refs/heads/master");
-    exception.expectMessage(botKey.get() + ",refs/heads/master");
-    return changeId;
+
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> apiCall.accept(changeId));
+    assertThat(thrown).hasMessageThat().contains("Branch level circular subscriptions detected");
+    assertThat(thrown).hasMessageThat().contains(topKey.get() + ",refs/heads/master");
+    assertThat(thrown).hasMessageThat().contains(midKey.get() + ",refs/heads/master");
+    assertThat(thrown).hasMessageThat().contains(botKey.get() + ",refs/heads/master");
   }
 
   @Test
   public void branchCircularSubscription() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submit();
+    testBranchCircularSubscription(changeId -> gApi.changes().id(changeId).current().submit());
   }
 
   @Test
   public void branchCircularSubscriptionPreview() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submitPreview();
+    testBranchCircularSubscription(
+        changeId -> gApi.changes().id(changeId).current().submitPreview());
   }
 
   @Test
@@ -672,10 +676,13 @@
     approve(getChangeId(subRepo, subMasterHead).get());
     approve(getChangeId(superRepo, superDevHead).get());
 
-    exception.expectMessage("Project level circular subscriptions detected");
-    exception.expectMessage(subKey.get());
-    exception.expectMessage(superKey.get());
-    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit();
+    Throwable thrown =
+        assertThrows(
+            Throwable.class,
+            () -> gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit());
+    assertThat(thrown).hasMessageThat().contains("Project level circular subscriptions detected");
+    assertThat(thrown).hasMessageThat().contains(subKey.get());
+    assertThat(thrown).hasMessageThat().contains(superKey.get());
   }
 
   @Test
@@ -898,6 +905,6 @@
   }
 
   private Project.NameKey nameKey(String s) {
-    return new Project.NameKey(name(s));
+    return Project.nameKey(name(s));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index c07d512..f8176a5 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.pgm;
 
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.StreamSubject.streams;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -195,7 +196,7 @@
       // Updating and searching old schema version works.
       Provider<InternalChangeQuery> queryProvider =
           ctx.getInjector().getProvider(InternalChangeQuery.class);
-      assertThat(queryProvider.get().byKey(new Change.Key(changeId))).hasSize(1);
+      assertThat(queryProvider.get().byKey(Change.key(changeId))).hasSize(1);
       assertThat(queryProvider.get().byTopicOpen("topic1")).isEmpty();
 
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -223,7 +224,7 @@
   }
 
   private void setUpChange() throws Exception {
-    project = new Project.NameKey("reindex-project-test");
+    project = Project.nameKey("reindex-project-test");
     try (ServerContext ctx = startServer()) {
       configureIndex(ctx.getInjector());
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -255,30 +256,31 @@
   }
 
   private void assertSearchVersion(ServerContext ctx, int expected) {
-    assertThat(
+    assertWithMessage("search version")
+        .that(
             ctx.getInjector()
                 .getInstance(ChangeIndexCollection.class)
                 .getSearchIndex()
                 .getSchema()
                 .getVersion())
-        .named("search version")
         .isEqualTo(expected);
   }
 
   private void assertWriteVersions(ServerContext ctx, Integer... expected) {
-    assertThat(
+    assertWithMessage("write versions")
+        .about(streams())
+        .that(
             ctx.getInjector().getInstance(ChangeIndexCollection.class).getWriteIndexes().stream()
                 .map(i -> i.getSchema().getVersion()))
-        .named("write versions")
         .containsExactlyElementsIn(ImmutableSet.copyOf(expected));
   }
 
   private void assertReady(int expectedReady) throws Exception {
     Set<Integer> allVersions = ChangeSchemaDefinitions.INSTANCE.getSchemas().keySet();
     GerritIndexStatus status = new GerritIndexStatus(sitePaths);
-    assertThat(
+    assertWithMessage("ready state for index versions")
+        .that(
             allVersions.stream().collect(toImmutableMap(v -> v, v -> status.getReady(CHANGES, v))))
-        .named("ready state for index versions")
         .isEqualTo(allVersions.stream().collect(toImmutableMap(v -> v, v -> v == expectedReady)));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 5e652c0..63e9ebf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -32,7 +32,7 @@
 
   public static void assertAccountInfos(List<TestAccount> expected, List<AccountInfo> actual) {
     Iterable<Account.Id> expectedIds = TestAccount.ids(expected);
-    Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> new Account.Id(a._accountId));
+    Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> 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/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index 84f218d..5adf46f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableMultimap;
@@ -149,16 +150,20 @@
   @Test
   public void setPreferredEmailToNonExistingEmail() throws Exception {
     String email = "non-existing@example.com";
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + email);
-    gApi.accounts().self().email(email).setPreferred();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.accounts().self().email(email).setPreferred());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + email);
   }
 
   @Test
   public void setPreferredEmailToEmailOfOtherAccount() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + user.email());
-    gApi.accounts().self().email(user.email()).setPreferred();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.accounts().self().email(user.email()).setPreferred());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + user.email());
   }
 
   @Test
@@ -201,9 +206,11 @@
     Context oldCtx =
         createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), user.email()));
     try {
-      exception.expect(ResourceConflictException.class);
-      exception.expectMessage("email in use by another account");
-      gApi.accounts().self().email(user.email()).setPreferred();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.accounts().self().email(user.email()).setPreferred());
+      assertThat(thrown).hasMessageThat().contains("email in use by another account");
     } finally {
       atrScope.set(oldCtx);
     }
@@ -248,9 +255,7 @@
 
     // Now the email is no longer found
     requestScopeOperations.resetCurrentApiUser();
-    emailApi = gApi.accounts().self().email(email);
-    exception.expect(ResourceNotFoundException.class);
-    emailApi.get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().self().email(email).get());
   }
 
   private Set<String> getEmails() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 8ff80a0..72ae859 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -112,9 +113,10 @@
   @Test
   public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts().id(admin.id().get()).getExternalIds();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.accounts().id(admin.id().get()).getExternalIds());
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   @Test
@@ -165,22 +167,30 @@
   public void deleteExternalIdsOfOtherUserNotAllowed() throws Exception {
     List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts()
-        .id(admin.id().get())
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.accounts()
+                    .id(admin.id().get())
+                    .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList())));
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   @Test
   public void deleteExternalIdOfOtherUserUnderOwnAccount_UnprocessableEntity() throws Exception {
     List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(String.format("External id %s does not exist", extIds.get(0).identity));
-    gApi.accounts()
-        .self()
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.accounts()
+                    .self()
+                    .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList())));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("External id %s does not exist", extIds.get(0).identity));
   }
 
   @Test
@@ -446,9 +456,11 @@
 
   @Test
   public void checkConsistencyNotAllowed() throws Exception {
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.config().server().checkConsistency(new ConsistencyCheckInput()));
+    assertThat(thrown).hasMessageThat().contains("access database not permitted");
   }
 
   private ConsistencyProblemInfo consistencyError(String message) {
@@ -687,7 +699,7 @@
   }
 
   private ExternalId createExternalIdForNonExistingAccount(String externalId) {
-    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+    return ExternalId.create(ExternalId.Key.parse(externalId), Account.id(1));
   }
 
   private ExternalId createExternalIdWithInvalidEmail(String externalId) {
@@ -714,7 +726,7 @@
   @Test
   public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
     ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
-    Account.Id accountId = new Account.Id(1024 * 100);
+    Account.Id accountId = Account.id(1024 * 100);
     accountsUpdateProvider
         .get()
         .insert(
@@ -760,8 +772,7 @@
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
 
-      exception.expect(IOException.class);
-      externalIds.byAccount(admin.id());
+      assertThrows(IOException.class, () -> externalIds.byAccount(admin.id()));
     }
   }
 
@@ -771,8 +782,7 @@
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
 
-      exception.expect(IOException.class);
-      externalIds.byEmail(admin.email());
+      assertThrows(IOException.class, () -> externalIds.byEmail(admin.email()));
     }
   }
 
@@ -975,7 +985,7 @@
     return info;
   }
 
-  private void allowPushOfExternalIds() throws IOException, ConfigInvalidException {
+  private void allowPushOfExternalIds() {
     grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
     grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 11f7c0f..931dace 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfo;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -24,9 +25,9 @@
 
 @NoHttpd
 public class GetAccountIT extends AbstractDaemonTest {
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getNonExistingAccount_NotFound() throws Exception {
-    gApi.accounts().id("non-existing").get();
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("non-existing").get());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 4dec505..a27a6a9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -106,11 +107,11 @@
     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());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
     ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
@@ -129,9 +130,10 @@
     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);
+    AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("label required to post review on behalf of \"" + in.onBehalfOf + '"');
   }
 
   @Test
@@ -143,9 +145,10 @@
     ReviewInput in = new ReviewInput().label("Not-A-Label", 5);
     in.onBehalfOf = user.id().toString();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Not-A-Label\" is not a configured label");
-    gApi.changes().id(changeId).current().review(in);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("label \"Not-A-Label\" is not a configured label");
   }
 
   @Test
@@ -175,10 +178,11 @@
     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);
+    AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
   }
 
   @Test
@@ -207,11 +211,11 @@
     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());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
     Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(cd.notes()));
@@ -266,9 +270,10 @@
     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);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+    assertThat(thrown).hasMessageThat().contains("not allowed to modify other user's drafts");
   }
 
   @Test
@@ -281,10 +286,10 @@
     in.onBehalfOf = "doesnotexist";
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage("doesnotexist");
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains("doesnotexist");
   }
 
   @Test
@@ -299,9 +304,11 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id() + " cannot see change");
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on_behalf_of account " + user.id() + " cannot see change");
   }
 
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
@@ -318,10 +325,10 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage(in.onBehalfOf);
-    revision.review(in);
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains(in.onBehalfOf);
   }
 
   @Test
@@ -338,8 +345,8 @@
     assertThat(cd.change().isMerged()).isTrue();
     PatchSetApproval submitter =
         approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
-    assertThat(submitter.getAccountId()).isEqualTo(admin2.id());
-    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id());
+    assertThat(submitter.accountId()).isEqualTo(admin2.id());
+    assertThat(submitter.realAccountId()).isEqualTo(admin.id());
   }
 
   @Test
@@ -350,10 +357,12 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = "doesnotexist";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage("doesnotexist");
-    gApi.changes().id(changeId).current().submit(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains("doesnotexist");
   }
 
   @Test
@@ -365,9 +374,15 @@
         .review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = admin2.email();
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of other users not permitted");
-    gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(project.get() + "~master~" + r.getChangeId())
+                    .current()
+                    .submit(in));
+    assertThat(thrown).hasMessageThat().contains("submit on behalf of other users not permitted");
   }
 
   @Test
@@ -380,9 +395,13 @@
     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 change");
-    gApi.changes().id(changeId).current().submit(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on_behalf_of account " + user.id() + " cannot see change");
   }
 
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
@@ -397,10 +416,12 @@
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = user.email();
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("not found");
-    exception.expectMessage(in.onBehalfOf);
-    gApi.changes().id(changeId).current().submit(in);
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.changes().id(changeId).current().submit(in));
+    assertThat(thrown).hasMessageThat().contains("not found");
+    assertThat(thrown).hasMessageThat().contains(in.onBehalfOf);
   }
 
   @Test
@@ -510,11 +531,11 @@
     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
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not user2
 
     ChangeData cd = r.getChange();
     ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
@@ -570,8 +591,7 @@
 
   private void blockRead(GroupInfo group) throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.block(
-          u.getConfig(), Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
+      Util.block(u.getConfig(), Permission.READ, AccountGroup.uuid(group.id), "refs/heads/master");
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index d0c1fa4..2c9107c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -114,9 +115,11 @@
     pwi.notifyNewPatchSets = true;
     projectsToWatch.add(pwi);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("duplicate entry for project " + projectName);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(thrown).hasMessageThat().contains("duplicate entry for project " + projectName);
   }
 
   @Test
@@ -146,9 +149,9 @@
     pwi.notifyNewChanges = true;
     pwi.notifyAllComments = true;
     projectsToWatch.add(pwi);
-
-    exception.expect(UnprocessableEntityException.class);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    assertThrows(
+        UnprocessableEntityException.class,
+        () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
index 27df565..b9c072c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
@@ -153,8 +153,8 @@
       RestApiCallHelper.execute(
           adminRestSession,
           TEST_CALLS.asList(),
-          String.valueOf(patchSetId.changeId.id),
-          String.valueOf(patchSetId.patchSetId));
+          String.valueOf(patchSetId.changeId().get()),
+          String.valueOf(patchSetId.get()));
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index f191e08..dfb8087 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
@@ -23,8 +24,11 @@
 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 com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -37,8 +41,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -61,7 +65,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -81,6 +85,7 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
@@ -118,7 +123,6 @@
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Submit submitHandler;
 
@@ -148,11 +152,11 @@
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitToEmptyRepo() throws Exception {
+  public void submitToEmptyRepo() throws Throwable {
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     assertThat(actual).hasSize(1);
 
@@ -162,10 +166,10 @@
   }
 
   @Test
-  public void submitSingleChange() throws Exception {
+  public void submitSingleChange() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmit = getRemoteHead();
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
@@ -184,7 +188,7 @@
   }
 
   @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Exception {
+  public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -224,7 +228,7 @@
           break;
         case REBASE_IF_NECESSARY:
         case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().getRevision().get();
+          String change2hash = change2.getChange().currentPatchSet().commitId().name();
           assertThat(e.getMessage())
               .isEqualTo(
                   "Cannot rebase "
@@ -268,19 +272,19 @@
   }
 
   @Test
-  public void submitMultipleChangesPreview() throws Exception {
+  public void submitMultipleChangesPreview() throws Throwable {
     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<BranchNameKey, 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"));
+    assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
       // CherryPick ignores dependencies, thus only change and destination
       // branch refs are modified.
@@ -306,7 +310,7 @@
   }
 
   @Test
-  public void submitNoPermission() throws Exception {
+  public void submitNoPermission() throws Throwable {
     // create project where submit is blocked
     Project.NameKey p = projectOperations.newProject().create();
     block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
@@ -320,7 +324,7 @@
   }
 
   @Test
-  public void noSelfSubmit() throws Exception {
+  public void noSelfSubmit() throws Throwable {
     // create project where submit is blocked for the change owner
     Project.NameKey p = projectOperations.newProject().create();
     try (ProjectConfigUpdate u = updateProject(p)) {
@@ -346,7 +350,7 @@
   }
 
   @Test
-  public void onlySelfSubmit() throws Exception {
+  public void onlySelfSubmit() throws Throwable {
     // create project where only the change owner can submit
     Project.NameKey p = projectOperations.newProject().create();
     try (ProjectConfigUpdate u = updateProject(p)) {
@@ -373,7 +377,7 @@
   }
 
   @Test
-  public void submitWholeTopicMultipleProjects() throws Exception {
+  public void submitWholeTopicMultipleProjects() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
@@ -409,7 +413,7 @@
   }
 
   @Test
-  public void submitWholeTopicMultipleBranchesOnSameProject() throws Exception {
+  public void submitWholeTopicMultipleBranchesOnSameProject() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
@@ -451,7 +455,7 @@
   }
 
   @Test
-  public void submitWholeTopic() throws Exception {
+  public void submitWholeTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
@@ -489,7 +493,7 @@
   }
 
   @Test
-  public void submitReusingOldTopic() throws Exception {
+  public void submitReusingOldTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     String topic = "test-topic";
@@ -520,13 +524,13 @@
   }
 
   private void assertSubmittedTogether(String changeId, Iterable<String> expected)
-      throws Exception {
+      throws Throwable {
     assertThat(gApi.changes().id(changeId).submittedTogether().stream().map(i -> i.changeId))
         .containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void submitWorkInProgressChange() throws Exception {
+  public void submitWorkInProgressChange() throws Throwable {
     PushOneCommit.Result change = pushTo("refs/for/master%wip");
     Change.Id num = change.getChange().getId();
     submitWithConflict(
@@ -540,12 +544,12 @@
   }
 
   @Test
-  public void submitWithHiddenBranchInSameTopic() throws Exception {
+  public void submitWithHiddenBranchInSameTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     PushOneCommit.Result visible = createChange("refs/for/master/" + name("topic"));
     Change.Id num = visible.getChange().getId();
 
-    createBranch(new Branch.NameKey(project, "hidden"));
+    createBranch(BranchNameKey.create(project, "hidden"));
     PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
     approve(hidden.getChangeId());
     blockRead("refs/heads/hidden");
@@ -558,7 +562,7 @@
   }
 
   @Test
-  public void submitChangeWhenParentOfOtherBranchTip() throws Exception {
+  public void submitChangeWhenParentOfOtherBranchTip() throws Throwable {
     // Chain of two commits
     // Push both to topic-branch
     // Push the first commit for review and submit
@@ -596,7 +600,7 @@
   }
 
   @Test
-  public void submitMergeOfNonChangeBranchTip() throws Exception {
+  public void submitMergeOfNonChangeBranchTip() throws Throwable {
     // Merge a branch with commits that have not been submitted as
     // changes.
     //
@@ -623,7 +627,7 @@
   }
 
   @Test
-  public void submitMergeOfNonChangeBranchNonTip() throws Exception {
+  public void submitMergeOfNonChangeBranchNonTip() throws Throwable {
     // Merge a branch with commits that have not been submitted as
     // changes.
     //
@@ -667,7 +671,7 @@
   }
 
   @Test
-  public void submitChangeWithCommitThatWasAlreadyMerged() throws Exception {
+  public void submitChangeWithCommitThatWasAlreadyMerged() throws Throwable {
     // create and submit a change
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
@@ -684,7 +688,7 @@
   }
 
   @Test
-  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Exception {
+  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Throwable {
     // create and submit 2 changes
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
@@ -708,7 +712,7 @@
   }
 
   @Test
-  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Exception {
+  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     // create and submit 2 changes with the same topic
@@ -732,7 +736,7 @@
   }
 
   @Test
-  public void submitWithValidation() throws Exception {
+  public void submitWithValidation() throws Throwable {
     AtomicBoolean called = new AtomicBoolean(false);
     this.addOnSubmitValidationListener(
         args -> {
@@ -754,7 +758,7 @@
   }
 
   @Test
-  public void submitWithValidationMultiRepo() throws Exception {
+  public void submitWithValidationMultiRepo() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
 
@@ -817,7 +821,7 @@
   }
 
   @Test
-  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
+  public void submitWithCommitAndItsMergeCommitTogether() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     RevCommit initialHead = getRemoteHead();
@@ -888,7 +892,7 @@
   }
 
   @Test
-  public void retrySubmitSingleChangeOnLockFailure() throws Exception {
+  public void retrySubmitSingleChangeOnLockFailure() throws Throwable {
     PushOneCommit.Result change = createChange();
     String id = change.getChangeId();
     approve(id);
@@ -913,7 +917,7 @@
   }
 
   @Test
-  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
+  public void retrySubmitAfterTornTopicOnLockFailure() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
     String topic = "test-topic";
@@ -962,7 +966,7 @@
   }
 
   @Test
-  public void authorAndCommitDateAreEqual() throws Exception {
+  public void authorAndCommitDateAreEqual() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     ConfigInput ci = new ConfigInput();
@@ -988,7 +992,7 @@
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
-  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -1005,7 +1009,7 @@
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Throwable {
     assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -1018,18 +1022,20 @@
     ChangeApi revert2 = gApi.changes().id(change.getChangeId()).revert();
     approve(revert2.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Change "
-            + revert2.get()._number
-            + ": Change could not be merged because the commit is empty. "
-            + "Project policy requires all commits to contain modifications to at least one file.");
-    revert2.current().submit();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revert2.current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Change "
+                + revert2.get()._number
+                + ": Change could not be merged because the commit is empty. Project policy"
+                + " requires all commits to contain modifications to at least one file.");
   }
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
-  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Throwable {
     ChangeInput ci = new ChangeInput();
     ci.subject = "Empty change";
     ci.project = project.get();
@@ -1041,7 +1047,7 @@
 
   @Test
   @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Exception {
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Throwable {
     ChangeInput ci = new ChangeInput();
     ci.subject = "Empty change";
     ci.project = project.get();
@@ -1049,22 +1055,24 @@
     ChangeApi change = gApi.changes().create(ci);
     approve(change.id());
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Change "
-            + change.get()._number
-            + ": Change could not be merged because the commit is empty. "
-            + "Project policy requires all commits to contain modifications to at least one file.");
-    change.current().submit();
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> change.current().submit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Change "
+                + change.get()._number
+                + ": Change could not be merged because the commit is empty. Project policy"
+                + " requires all commits to contain modifications to at least one file.");
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Throwable {
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     assertThat(actual).hasSize(1);
 
@@ -1075,18 +1083,16 @@
 
   @Test
   @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
-  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Throwable {
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change =
         pushFactory
             .create(admin.newIdent(), testRepo, "Change 1", ImmutableMap.of())
             .to("refs/for/master");
     change.assertOkStatus();
-    // TODO(dborowitz): Use EMPTY_TREE_ID after upgrading to https://git.eclipse.org/r/127473
-    assertThat(change.getCommit().getTree())
-        .isEqualTo(ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904"));
+    assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
 
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     assertThat(actual).hasSize(1);
 
@@ -1095,7 +1101,7 @@
     assertTrees(project, actual);
   }
 
-  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
+  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
           batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.nowTs())) {
@@ -1114,7 +1120,7 @@
     }
   }
 
-  private void assertSubmitter(PushOneCommit.Result change) throws Exception {
+  private void assertSubmitter(PushOneCommit.Result change) throws Throwable {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
     Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
@@ -1137,102 +1143,86 @@
     }
   }
 
-  protected void submit(String changeId) throws Exception {
+  protected void submit(String changeId) throws Throwable {
     submit(changeId, new SubmitInput(), null, null);
   }
 
-  protected void submit(String changeId, SubmitInput input) throws Exception {
+  protected void submit(String changeId, SubmitInput input) throws Throwable {
     submit(changeId, input, null, null);
   }
 
-  protected void submitWithConflict(String changeId, String expectedError) throws Exception {
+  protected void submitWithConflict(String changeId, String expectedError) throws Throwable {
     submit(changeId, new SubmitInput(), ResourceConflictException.class, expectedError);
   }
 
   protected void submit(
       String changeId,
       SubmitInput input,
-      Class<? extends RestApiException> expectedExceptionType,
+      @Nullable Class<? extends RestApiException> expectedExceptionType,
       String expectedExceptionMsg)
-      throws Exception {
+      throws Throwable {
     approve(changeId);
     if (expectedExceptionType == null) {
       assertSubmittable(changeId);
+    } else {
+      requireNonNull(expectedExceptionMsg);
     }
-    try {
-      gApi.changes().id(changeId).current().submit(input);
-      if (expectedExceptionType != null) {
-        fail("Expected exception of type " + expectedExceptionType.getSimpleName());
-      }
-    } catch (RestApiException e) {
-      if (expectedExceptionType == null) {
-        throw e;
-      }
-      // More verbose than using assertThat and/or ExpectedException, but gives
-      // 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);
-      }
+    ThrowingRunnable submit = () -> gApi.changes().id(changeId).current().submit(input);
+    if (expectedExceptionType != null) {
+      RestApiException thrown = assertThrows(expectedExceptionType, submit);
+      assertThat(thrown).hasMessageThat().isEqualTo(expectedExceptionMsg);
       return;
     }
+    submit.run();
     ChangeInfo change = gApi.changes().id(changeId).info();
     assertMerged(change.changeId);
   }
 
-  protected void assertSubmittable(String changeId) throws Exception {
-    assertThat(get(changeId, SUBMITTABLE).submittable).named("submit bit on ChangeInfo").isTrue();
+  protected void assertSubmittable(String changeId) throws Throwable {
+    assertWithMessage("submit bit on ChangeInfo")
+        .that(get(changeId, SUBMITTABLE).submittable)
+        .isTrue();
     RevisionResource rsrc = parseCurrentRevisionResource(changeId);
     UiAction.Description desc = submitHandler.getDescription(rsrc);
-    assertThat(desc.isVisible()).named("visible bit on submit action").isTrue();
-    assertThat(desc.isEnabled()).named("enabled bit on submit action").isTrue();
+    assertWithMessage("visible bit on submit action").that(desc.isVisible()).isTrue();
+    assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
   }
 
-  protected void assertChangeMergedEvents(String... expected) throws Exception {
+  protected void assertChangeMergedEvents(String... expected) throws Throwable {
     eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
   }
 
-  protected void assertRefUpdatedEvents(RevCommit... expected) throws Exception {
+  protected void assertRefUpdatedEvents(RevCommit... expected) throws Throwable {
     eventRecorder.assertRefUpdatedEvents(project.get(), "refs/heads/master", expected);
   }
 
   protected void assertCurrentRevision(String changeId, int expectedNum, ObjectId expectedId)
-      throws Exception {
+      throws Throwable {
     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(Project.nameKey(c.project))) {
+      String refName = PatchSet.id(Change.id(c._number), expectedNum).toRefName();
       Ref ref = repo.exactRef(refName);
-      assertThat(ref).named(refName).isNotNull();
+      assertWithMessage(refName).that(ref).isNotNull();
       assertThat(ref.getObjectId()).isEqualTo(expectedId);
     }
   }
 
-  protected void assertNew(String changeId) throws Exception {
+  protected void assertNew(String changeId) throws Throwable {
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
   }
 
-  protected void assertApproved(String changeId) throws Exception {
+  protected void assertApproved(String changeId) throws Throwable {
     assertApproved(changeId, admin);
   }
 
-  protected void assertApproved(String changeId, TestAccount user) throws Exception {
+  protected void assertApproved(String changeId, TestAccount user) throws Throwable {
     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.id());
+    assertThat(Account.id(cr.all.get(0)._accountId)).isEqualTo(user.id());
   }
 
   protected void assertMerged(String changeId) throws RestApiException {
@@ -1252,37 +1242,37 @@
         .isEqualTo(commit.getCommitterIdent().getTimeZone());
   }
 
-  protected void assertSubmitter(String changeId, int psId) throws Exception {
+  protected void assertSubmitter(String changeId, int psId) throws Throwable {
     assertSubmitter(changeId, psId, admin);
   }
 
-  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Exception {
+  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Throwable {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
     ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(cn, new PatchSet.Id(cn.getChangeId(), psId));
+        approvalsUtil.getSubmitter(cn, PatchSet.id(cn.getChangeId(), psId));
     assertThat(submitter).isNotNull();
     assertThat(submitter.isLegacySubmit()).isTrue();
-    assertThat(submitter.getAccountId()).isEqualTo(user.id());
+    assertThat(submitter.accountId()).isEqualTo(user.id());
   }
 
-  protected void assertNoSubmitter(String changeId, int psId) throws Exception {
+  protected void assertNoSubmitter(String changeId, int psId) throws Throwable {
     Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
     ChangeNotes cn = notesFactory.createChecked(c);
     PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(cn, new PatchSet.Id(cn.getChangeId(), psId));
+        approvalsUtil.getSubmitter(cn, PatchSet.id(cn.getChangeId(), psId));
     assertThat(submitter).isNull();
   }
 
   protected void assertCherryPick(TestRepository<?> testRepo, boolean contentMerge)
-      throws Exception {
+      throws Throwable {
     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 Throwable {
     Repository repo = testRepo.getRepository();
     RevCommit localHead = getHead(repo, "HEAD");
     RevCommit remoteHead = getRemoteHead();
@@ -1294,7 +1284,7 @@
     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 Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.markStart(rw.parseCommit(repo.exactRef("refs/heads/" + branch).getObjectId()));
@@ -1302,7 +1292,7 @@
     }
   }
 
-  protected List<RevCommit> getRemoteLog() throws Exception {
+  protected List<RevCommit> getRemoteLog() throws Throwable {
     return getRemoteLog(project, "master");
   }
 
@@ -1311,13 +1301,13 @@
     onSubmitValidatorHandle = onSubmitValidationListeners.add("gerrit", listener);
   }
 
-  private String getLatestDiff(Repository repo) throws Exception {
+  private String getLatestDiff(Repository repo) throws Throwable {
     ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
     ObjectId newTreeId = repo.resolve("HEAD^{tree}");
     return getLatestDiff(repo, oldTreeId, newTreeId);
   }
 
-  private String getLatestRemoteDiff() throws Exception {
+  private String getLatestRemoteDiff() throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       ObjectId oldTreeId = repo.resolve("refs/heads/master~1^{tree}");
@@ -1327,7 +1317,7 @@
   }
 
   private String getLatestDiff(Repository repo, ObjectId oldTreeId, ObjectId newTreeId)
-      throws Exception {
+      throws Throwable {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     try (DiffFormatter fmt = new DiffFormatter(out)) {
       fmt.setRepository(repo);
@@ -1338,7 +1328,7 @@
   }
 
   // TODO(hanwen): the submodule tests have a similar method; maybe we could share code?
-  protected Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
+  protected Project.NameKey createProjectForPush(SubmitType submitType) throws Throwable {
     Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
     grant(project, "refs/heads/*", Permission.PUSH);
     grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
@@ -1346,7 +1336,7 @@
   }
 
   protected PushOneCommit.Result createChange(
-      String subject, String fileName, String content, String topic) throws Exception {
+      String subject, String fileName, String content, String topic) throws Throwable {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
     return push.to("refs/for/master/" + name(topic));
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 36a09fd..cad06fb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -26,7 +26,7 @@
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
 
   @Test
-  public void submitWithMerge() throws Exception {
+  public void submitWithMerge() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -43,7 +43,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
+  public void submitWithContentMerge() throws Throwable {
     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");
@@ -61,7 +61,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
+  public void submitWithContentMerge_Conflict() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -83,7 +83,7 @@
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Exception {
+  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Throwable {
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
     approve(change1.getChangeId());
@@ -93,7 +93,7 @@
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
+  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     PushOneCommit.Result change1 =
         pushFactory
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index c12adfa..18a9a24 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -29,7 +29,7 @@
 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.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
@@ -47,13 +47,13 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebase() throws Exception {
+  public void submitWithRebase() throws Throwable {
     submitWithRebase(admin);
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
+  public void submitWithRebaseWithoutAddPatchSetPermission() throws Throwable {
     try (ProjectConfigUpdate u = updateProject(project)) {
       Util.block(u.getConfig(), Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
       Util.allow(u.getConfig(), Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
@@ -71,7 +71,7 @@
   }
 
   protected ImmutableList<PushOneCommit.Result> submitWithRebase(TestAccount submitter)
-      throws Exception {
+      throws Throwable {
     requestScopeOperations.setApiUser(submitter.id());
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
@@ -102,7 +102,7 @@
   }
 
   @Test
-  public void submitWithRebaseMultipleChanges() throws Exception {
+  public void submitWithRebaseMultipleChanges() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content");
     submit(change1.getChangeId());
@@ -163,7 +163,7 @@
   }
 
   @Test
-  public void submitWithRebaseMergeCommit() throws Exception {
+  public void submitWithRebaseMergeCommit() throws Throwable {
     /*
        *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
        |\
@@ -219,7 +219,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
+  public void submitWithContentMerge_Conflict() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -241,7 +241,7 @@
     assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
   }
 
-  protected RevCommit parse(ObjectId id) throws Exception {
+  protected RevCommit parse(ObjectId id) throws Throwable {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit c = rw.parseCommit(id);
@@ -251,7 +251,7 @@
   }
 
   @Test
-  public void submitAfterReorderOfCommits() throws Exception {
+  public void submitAfterReorderOfCommits() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     // Create two commits and push.
@@ -278,7 +278,7 @@
   }
 
   @Test
-  public void submitChangesAfterBranchOnSecond() throws Exception {
+  public void submitChangesAfterBranchOnSecond() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     PushOneCommit.Result change = createChange();
@@ -287,7 +287,7 @@
     PushOneCommit.Result change2 = createChange();
     approve(change2.getChangeId());
     Project.NameKey project = change2.getChange().change().getProject();
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     createBranchWithRevision(branch, change2.getCommit().getName());
     gApi.changes().id(change2.getChangeId()).current().submit();
     assertMerged(change2.getChangeId());
@@ -301,7 +301,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitFastForwardIdenticalTree() throws Exception {
+  public void submitFastForwardIdenticalTree() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
@@ -334,7 +334,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOne() throws Exception {
+  public void submitChainOneByOne() throws Throwable {
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
     submit(change1.getChangeId());
@@ -343,7 +343,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainFailsOnRework() throws Exception {
+  public void submitChainFailsOnRework() throws Throwable {
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     RevCommit headAfterChange1 = change1.getCommit();
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
@@ -364,7 +364,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOneManualRebase() throws Exception {
+  public void submitChainOneByOneManualRebase() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index e7f3d54..5fb42da 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -351,7 +351,7 @@
     String id = createChange().getChangeId();
     amendChange(id);
     ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
-    Change.Id changeId = new Change.Id(origChange._number);
+    Change.Id changeId = Change.id(origChange._number);
 
     class Visitor implements ActionVisitor {
       @Override
@@ -395,7 +395,7 @@
 
     // ...via ChangeJson directly.
     ChangeData cd = changeDataFactory.create(project, changeId);
-    revisionJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
+    revisionJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(PatchSet.id(changeId, 1)));
   }
 
   private void visitedCurrentRevisionActionsAssertions(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 2d6227b..bdb710c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
@@ -159,18 +160,16 @@
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
     PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
-    exception.expect(AuthException.class);
-    exception.expectMessage("read not permitted");
-    setAssignee(r, user.email());
+    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown).hasMessageThat().contains("read not permitted");
   }
 
   @Test
   public void setAssigneeNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted");
-    setAssignee(r, user.email());
+    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
+    assertThat(thrown).hasMessageThat().contains("not permitted");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
index 3f1608c..4632731 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
@@ -98,14 +98,14 @@
     // This test tests a redirect that is primarily intended for the UI (though the backend doesn't
     // really care who the caller is). The redirect rewrites a shorthand change number URL (/123) to
     // it's canonical long form (/c/project/+/123).
-    int changeId = createChange().getChange().getId().id;
+    int changeId = createChange().getChange().getId().get();
     RestResponse res = anonymousRestSession.get("/" + changeId);
     res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
   }
 
   @Test
   public void changeNumberRedirectsWithTrailingSlash() throws Exception {
-    int changeId = createChange().getChange().getId().id;
+    int changeId = createChange().getChange().getId().get();
     RestResponse res = anonymousRestSession.get("/" + changeId + "/");
     res.assertTemporaryRedirect("/c/" + project.get() + "/+/" + changeId + "/");
   }
@@ -125,8 +125,8 @@
   @Test
   public void hiddenChangeNotFound() throws Exception {
     Change.Id changeId = createChange().getChange().getId();
-    gApi.changes().id(changeId.id).setPrivate(true, null);
-    RestResponse res = anonymousRestSession.get("/" + changeId.id);
+    gApi.changes().id(changeId.get()).setPrivate(true, null);
+    RestResponse res = anonymousRestSession.get("/" + changeId.get());
     res.assertNotFound();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index 59b6e29..f05d4dc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.junit.Test;
 
 @NoHttpd
@@ -55,7 +55,7 @@
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags)
         .containsExactly("test-tag");
 
-    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+    createBranch(BranchNameKey.create(project, "test-branch"));
 
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().branches)
         .containsExactly("master", "test-branch");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 51c5fc8..1e7fd38 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -14,6 +14,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -279,7 +280,7 @@
     List<ChangeMessageInfo> messagesBeforeDeletion = gApi.changes().id(changeNum).messages();
 
     List<CommentInfo> commentsBefore = getChangeSortedComments(changeNum);
-    List<RevCommit> commitsBefore = getChangeMetaCommitsInReverseOrder(new Change.Id(changeNum));
+    List<RevCommit> commitsBefore = getChangeMetaCommitsInReverseOrder(Change.id(changeNum));
 
     String id = messagesBeforeDeletion.get(deletedMessageIndex).id;
     DeleteChangeMessageInput input = new DeleteChangeMessageInput(reason);
@@ -306,8 +307,8 @@
       int deletedMessageIndex,
       TestAccount deletedBy,
       String deleteReason) {
-    assertThat(messagesAfterDeletion)
-        .named("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
+    assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
+        .that(messagesAfterDeletion)
         .hasSize(messagesBeforeDeletion.size());
 
     for (int i = 0; i < messagesAfterDeletion.size(); ++i) {
@@ -340,8 +341,7 @@
       TestAccount deletedBy,
       String deleteReason)
       throws Exception {
-    List<RevCommit> commitsAfterDeletion =
-        getChangeMetaCommitsInReverseOrder(new Change.Id(changeNum));
+    List<RevCommit> commitsAfterDeletion = getChangeMetaCommitsInReverseOrder(Change.id(changeNum));
     assertThat(commitsAfterDeletion).hasSize(commitsBeforeDeletion.size());
 
     for (int i = 0; i < commitsBeforeDeletion.size(); i++) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index d51221e..70abe24 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -121,8 +123,7 @@
   }
 
   private void assertApproveFails(TestAccount a, String changeId) throws Exception {
-    exception.expect(AuthException.class);
-    approve(a, changeId);
+    assertThrows(AuthException.class, () -> approve(a, changeId));
   }
 
   private void grantApproveToChangeOwner(Project.NameKey project) throws Exception {
@@ -139,7 +140,7 @@
 
   private void grantApprove(Project.NameKey project, AccountGroup.UUID groupUUID, boolean exclusive)
       throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, groupUUID, exclusive);
+    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", groupUUID, exclusive);
   }
 
   private void blockApproveForChangeOwner(Project.NameKey project) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 173b78d..76f6b98 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
@@ -662,9 +663,11 @@
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
     requestScopeOperations.setApiUser(newUser.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -690,9 +693,11 @@
 
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     requestScopeOperations.setApiUser(newUser.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
@@ -705,9 +710,11 @@
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
     requestScopeOperations.setApiUser(newUser.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index 9a907aa..a4ca7a3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
@@ -36,10 +36,6 @@
 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;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Before;
 import org.junit.Test;
@@ -75,8 +71,8 @@
   }
 
   private String testUpdateProjectConfig() throws Exception {
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("project", null, "description")).isNull();
+    Config cfg = projectOperations.project(project).getConfig();
+    assertThat(cfg).stringValue("project", null, "description").isNull();
     String desc = "new project description";
     cfg.setString("project", null, "description", desc);
 
@@ -89,7 +85,12 @@
     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(
+            projectOperations
+                .project(project)
+                .getConfig()
+                .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;
@@ -107,8 +108,8 @@
     gApi.projects().create(parent);
 
     requestScopeOperations.setApiUser(user.id());
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("access", null, "inheritFrom")).isAnyOf(null, allProjects.get());
+    Config cfg = projectOperations.project(project).getConfig();
+    assertThat(cfg).stringValue("access", null, "inheritFrom").isAnyOf(null, allProjects.get());
     cfg.setString("access", null, "inheritFrom", parent.name);
 
     PushOneCommit.Result r = createConfigChange(cfg);
@@ -133,7 +134,8 @@
 
     assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(allProjects.get());
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom"))
+    assertThat(
+            projectOperations.project(project).getConfig().getString("access", null, "inheritFrom"))
         .isAnyOf(null, allProjects.get());
 
     requestScopeOperations.setApiUser(admin.id());
@@ -141,7 +143,9 @@
     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(
+            projectOperations.project(project).getConfig().getString("access", null, "inheritFrom"))
+        .isEqualTo(parent.name);
   }
 
   @Test
@@ -179,17 +183,6 @@
     testRepo.reset(RefNames.REFS_CONFIG);
   }
 
-  private Config readProjectConfig() throws Exception {
-    RevWalk rw = testRepo.getRevWalk();
-    RevTree tree = rw.parseTree(testRepo.getRepository().resolve("HEAD"));
-    RevObject obj = rw.parseAny(testRepo.get(tree, "project.config"));
-    ObjectLoader loader = rw.getObjectReader().open(obj);
-    String text = new String(loader.getCachedBytes(), UTF_8);
-    Config cfg = new Config();
-    cfg.fromText(text);
-    return cfg;
-  }
-
   private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
     PushOneCommit.Result r =
         pushFactory
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 9c42542..3b26459 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -26,6 +26,7 @@
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -80,11 +81,11 @@
     String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
     String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
 
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-    assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-    assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-    assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-    assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isNull();
+    assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNull();
   }
 
   @Test
@@ -162,7 +163,7 @@
     res.assertOK();
 
     String vary = res.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
+    assertWithMessage(VARY).that(vary).isNotNull();
     assertThat(Splitter.on(", ").splitToList(vary))
         .containsExactly(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
     checkCors(res, true, origin);
@@ -213,7 +214,7 @@
         auth = c.getValue();
       }
     }
-    assertThat(auth).named("GerritAccount cookie").isNotNull();
+    assertWithMessage("GerritAccount cookie").that(auth).isNotNull();
     cookies.clear();
 
     UrlEncoded url =
@@ -232,16 +233,18 @@
     assertThat(r.getStatusLine().getStatusCode()).isEqualTo(200);
 
     Header vary = r.getFirstHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary.getValue())).named(VARY).contains(ORIGIN);
+    assertWithMessage(VARY).that(vary).isNotNull();
+    assertWithMessage(VARY).that(Splitter.on(", ").splitToList(vary.getValue())).contains(ORIGIN);
 
     Header allowOrigin = r.getFirstHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNotNull();
-    assertThat(allowOrigin.getValue()).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNotNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin.getValue()).isEqualTo(origin);
 
     Header allowAuth = r.getFirstHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
-    assertThat(allowAuth).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNotNull();
-    assertThat(allowAuth.getValue()).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowAuth).isNotNull();
+    assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS)
+        .that(allowAuth.getValue())
+        .isEqualTo("true");
 
     checkTopic(change, "test-xd");
   }
@@ -264,7 +267,7 @@
 
   private void checkTopic(Result change, @Nullable String topic) throws RestApiException {
     ChangeInfo info = gApi.changes().id(change.getChangeId()).get();
-    StringSubject t = assertThat(info.topic).named("topic");
+    StringSubject t = assertWithMessage("topic").that(info.topic);
     if (topic != null) {
       t.isEqualTo(topic);
     } else {
@@ -287,8 +290,8 @@
 
   private void checkCors(RestResponse r, boolean accept, String origin) {
     String vary = r.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary)).named(VARY).contains(ORIGIN);
+    assertWithMessage(VARY).that(vary).isNotNull();
+    assertWithMessage(VARY).that(Splitter.on(", ").splitToList(vary)).contains(ORIGIN);
 
     String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
     String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
@@ -296,28 +299,28 @@
     String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
     String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
     if (accept) {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isEqualTo("600");
+      assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isEqualTo(origin);
+      assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isEqualTo("true");
+      assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isEqualTo("600");
 
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowMethods))
-          .named(ACCESS_CONTROL_ALLOW_METHODS)
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNotNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS)
+          .that(Splitter.on(", ").splitToList(allowMethods))
           .containsExactly("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
 
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowHeaders))
-          .named(ACCESS_CONTROL_ALLOW_HEADERS)
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNotNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS)
+          .that(Splitter.on(", ").splitToList(allowHeaders))
           .containsExactlyElementsIn(
               Stream.of(AUTHORIZATION, CONTENT_TYPE, "X-Gerrit-Auth", "X-Requested-With")
                   .map(s -> s.toLowerCase(Locale.US))
                   .collect(ImmutableSet.toImmutableSet()));
     } else {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_ORIGIN).that(allowOrigin).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_CREDENTIALS).that(allowCred).isNull();
+      assertWithMessage(ACCESS_CONTROL_MAX_AGE).that(maxAge).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_METHODS).that(allowMethods).isNull();
+      assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNull();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 2a8293f..2eb85d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
@@ -46,7 +47,7 @@
 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.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
@@ -274,7 +275,7 @@
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+          rw.parseCommit(repo.exactRef(changeMetaRef(Change.id(c._number))).getObjectId());
 
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
@@ -449,7 +450,7 @@
 
   @Test
   public void createChangeOnExistingBranchNotPermitted() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
     blockRead("refs/heads/*");
     requestScopeOperations.setApiUser(user.id());
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
@@ -507,9 +508,8 @@
   private void assertCreateFails(
       ChangeInput in, Class<? extends RestApiException> errType, String errSubstring)
       throws Exception {
-    exception.expect(errType);
-    exception.expectMessage(errSubstring);
-    gApi.changes().create(in);
+    Throwable thrown = assertThrows(errType, () -> gApi.changes().create(in));
+    assertThat(thrown).hasMessageThat().contains(errSubstring);
   }
 
   // TODO(davido): Expose setting of account preferences in the API
@@ -568,8 +568,8 @@
     initialCommit.assertOkStatus();
 
     // create two new branches
-    createBranch(new Branch.NameKey(project, branchA));
-    createBranch(new Branch.NameKey(project, branchB));
+    createBranch(BranchNameKey.create(project, branchA));
+    createBranch(BranchNameKey.create(project, branchB));
 
     // create a commit in branchA
     Result changeA =
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index b0fad7e..631c3d8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -104,6 +104,6 @@
   }
 
   private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+    return Iterables.transform(r, a -> Account.id(a._accountId));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index c13df49..0c5498b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.rest.change;
 
 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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
@@ -78,9 +80,9 @@
   public void addInvalidHashtag() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("hashtags may not contain commas");
-    addHashtags(r, "invalid,hashtag");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> addHashtags(r, "invalid,hashtag"));
+    assertThat(thrown).hasMessageThat().contains("hashtags may not contain commas");
   }
 
   @Test
@@ -258,9 +260,8 @@
   public void addHashtagWithoutPermissionNotAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit hashtags not permitted");
-    addHashtags(r, "MyHashtag");
+    AuthException thrown = assertThrows(AuthException.class, () -> addHashtags(r, "MyHashtag"));
+    assertThat(thrown).hasMessageThat().contains("edit hashtags not permitted");
   }
 
   @Test
@@ -304,7 +305,7 @@
   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();
+    assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
     return lastMessage;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index f49d1fb..0087268 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -66,7 +66,7 @@
       Util.allow(
           u.getConfig(),
           Permission.READ,
-          groupCache.get(new AccountGroup.NameKey(group)).get().getGroupUUID(),
+          groupCache.get(AccountGroup.nameKey(group)).get().getGroupUUID(),
           "refs/*");
       Util.block(u.getConfig(), Permission.READ, REGISTERED_USERS, "refs/*");
       u.save();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 2448ff881c..696e161 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -36,7 +37,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.inject.Inject;
@@ -54,9 +55,9 @@
   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");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    move(r.getChangeId(), newBranch.getShortName());
+    move(r.getChangeId(), newBranch.shortName());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
   }
 
@@ -64,9 +65,9 @@
   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");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    move(r.getChangeId(), newBranch.get());
+    move(r.getChangeId(), newBranch.branch());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
   }
 
@@ -74,10 +75,10 @@
   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");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     String moveMessage = "Moving for the move test";
-    move(r.getChangeId(), newBranch.get(), moveMessage);
+    move(r.getChangeId(), newBranch.branch(), moveMessage);
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
     StringBuilder expectedMessage = new StringBuilder();
     expectedMessage.append("Change destination moved from master to moveTest");
@@ -90,49 +91,59 @@
   public void moveChangeToSameRefAsCurrent() throws Exception {
     // Move change to the branch same as change's destination
     PushOneCommit.Result r = createChange();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already destined for the specified branch");
-    move(r.getChangeId(), r.getChange().change().getDest().get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> move(r.getChangeId(), r.getChange().change().getDest().branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Change is already destined for the specified branch");
   }
 
   @Test
   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");
+    BranchNameKey newBranch = BranchNameKey.create(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());
-    move(changeNum, newBranch.get());
+    createChange(newBranch.branch(), r.getChangeId());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> move(changeNum, newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Destination "
+                + newBranch.shortName()
+                + " has a different change with same change key "
+                + r.getChangeId());
   }
 
   @Test
   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");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Destination " + newBranch.get() + " not found in the project");
-    move(r.getChangeId(), newBranch.get());
+    BranchNameKey newBranch =
+        BranchNameKey.create(r.getChange().change().getProject(), "does_not_exist");
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Destination " + newBranch.branch() + " not found in the project");
   }
 
   @Test
   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");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     merge(r);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is merged");
-    move(r.getChangeId(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("Change is merged");
   }
 
   @Test
@@ -153,43 +164,46 @@
     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");
+    BranchNameKey newBranch =
+        BranchNameKey.create(r1.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Merge commit cannot be moved");
-    move(GitUtil.getChangeId(testRepo, c).get(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> move(GitUtil.getChangeId(testRepo, c).get(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("Merge commit cannot be moved");
   }
 
   @Test
   public void moveChangeToBranchWithoutUploadPerms() throws Exception {
     // Move change to a destination where user doesn't have upload permissions
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
+    BranchNameKey newBranch =
+        BranchNameKey.create(r.getChange().change().getProject(), "blocked_branch");
     createBranch(newBranch);
     block(
-        "refs/for/" + newBranch.get(),
+        "refs/for/" + newBranch.branch(),
         Permission.PUSH,
         systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("move not permitted");
   }
 
   @Test
   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");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     block(
-        r.getChange().change().getDest().get(),
+        r.getChange().change().getDest().branch(),
         Permission.ABANDON,
         systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown).hasMessageThat().contains("move not permitted");
   }
 
   @Test
@@ -202,23 +216,24 @@
     int changeNum = r.getChange().change().getChangeId();
 
     // Create a branch with that same commit
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchNameKey newBranch = BranchNameKey.create(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.project().get()).branch(newBranch.branch()).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());
-    move(changeNum, newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> move(changeNum, newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Current patchset revision is reachable from tip of " + newBranch.branch());
   }
 
   @Test
   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");
+    BranchNameKey newBranch = BranchNameKey.create(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
 
     try (ProjectConfigUpdate u = updateProject(project)) {
@@ -237,17 +252,20 @@
     grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("The current patch set of change %s is locked", r.getChange().getId()));
-    move(r.getChangeId(), newBranch.get());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> move(r.getChangeId(), newBranch.branch()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format("The current patch set of change %s is locked", r.getChange().getId()));
   }
 
   @Test
   public void moveChangeOnlyKeepVetoVotes() throws Exception {
     // A vote for a label will be kept after moving if the label's function is *WithBlock and the
     // vote holds the minimum value.
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
 
     String codeReviewLabel = "Code-Review"; // 'Code-Review' uses 'MaxWithBlock' function.
     String testLabelA = "Label-A";
@@ -302,7 +320,7 @@
 
   @Test
   public void moveToBranchWithoutLabel() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
+    createBranch(BranchNameKey.create(project, "foo"));
     String testLabelA = "Label-A";
     configLabel(testLabelA, LabelFunction.MAX_WITH_BLOCK, Arrays.asList("refs/heads/master"));
 
@@ -333,9 +351,9 @@
   public void moveNoDestinationBranchSpecified() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("destination branch is required");
-    move(r.getChangeId(), null);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> move(r.getChangeId(), null));
+    assertThat(thrown).hasMessageThat().contains("destination branch is required");
   }
 
   @Test
@@ -343,9 +361,9 @@
   public void moveCanBeDisabledByConfig() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("move changes endpoint is disabled");
-    move(r.getChangeId(), null);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> move(r.getChangeId(), null));
+    assertThat(thrown).hasMessageThat().contains("move changes endpoint is disabled");
   }
 
   private void move(int changeNum, String destination) throws RestApiException {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
index ca4288f6..e0bca3a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -81,9 +82,9 @@
     setPrivateByDefault(project2, InheritableBoolean.TRUE);
 
     ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("private changes are disabled");
-    gApi.changes().create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().create(input));
+    assertThat(thrown).hasMessageThat().contains("private changes are disabled");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 2ad4aca..7dbcbc1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -47,7 +47,7 @@
   }
 
   @Test
-  public void submitWithCherryPickIfFastForwardPossible() throws Exception {
+  public void submitWithCherryPickIfFastForwardPossible() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
@@ -60,7 +60,7 @@
   }
 
   @Test
-  public void submitWithCherryPick() throws Exception {
+  public void submitWithCherryPick() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -85,13 +85,13 @@
   }
 
   @Test
-  public void changeMessageOnSubmit() throws Exception {
+  public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
     RegistrationHandle handle =
         changeMessageModifiers.add(
             "gerrit",
             (newCommitMessage, original, mergeTip, destination) ->
-                newCommitMessage + "Custom: " + destination.get());
+                newCommitMessage + "Custom: " + destination.branch());
     try {
       submit(change.getChangeId());
     } finally {
@@ -107,7 +107,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
+  public void submitWithContentMerge() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
@@ -145,7 +145,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
+  public void submitWithContentMerge_Conflict() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -171,7 +171,7 @@
   }
 
   @Test
-  public void submitOutOfOrder() throws Exception {
+  public void submitOutOfOrder() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -199,7 +199,7 @@
   }
 
   @Test
-  public void submitOutOfOrder_Conflict() throws Exception {
+  public void submitOutOfOrder_Conflict() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -226,7 +226,7 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
+  public void submitMultipleChanges() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
@@ -254,7 +254,7 @@
   }
 
   @Test
-  public void submitDependentNonConflictingChangesOutOfOrder() throws Exception {
+  public void submitDependentNonConflictingChangesOutOfOrder() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
@@ -290,7 +290,7 @@
   }
 
   @Test
-  public void submitDependentConflictingChangesOutOfOrder() throws Exception {
+  public void submitDependentConflictingChangesOutOfOrder() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
@@ -322,7 +322,7 @@
   }
 
   @Test
-  public void submitSubsetOfDependentChanges() throws Exception {
+  public void submitSubsetOfDependentChanges() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
@@ -345,7 +345,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitIdenticalTree() throws Exception {
+  public void submitIdenticalTree() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 974180c..bfc4ae3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -36,7 +36,7 @@
   }
 
   @Test
-  public void submitWithFastForward() throws Exception {
+  public void submitWithFastForward() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
@@ -50,7 +50,7 @@
   }
 
   @Test
-  public void submitMultipleChangesWithFastForward() throws Exception {
+  public void submitMultipleChangesWithFastForward() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     PushOneCommit.Result change = createChange();
@@ -82,12 +82,12 @@
   }
 
   @Test
-  public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
+  public void submitTwoChangesWithFastForward_missingDependency() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
-    Change.Id id1 = change1.getPatchSetId().getParentKey();
+    Change.Id id1 = change1.getPatchSetId().changeId();
     submitWithConflict(
         change2.getChangeId(),
         "Failed to submit 2 changes due to the following problems:\n"
@@ -102,7 +102,7 @@
   }
 
   @Test
-  public void submitFastForwardNotPossible_Conflict() throws Exception {
+  public void submitFastForwardNotPossible_Conflict() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -136,7 +136,7 @@
   }
 
   @Test
-  public void submitSameCommitsAsInExperimentalBranch() throws Exception {
+  public void submitSameCommitsAsInExperimentalBranch() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     grant(project, "refs/heads/*", Permission.CREATE);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
index 9bc5a2f..3b835a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
@@ -29,7 +29,7 @@
   }
 
   @Test
-  public void submitWithMergeIfFastForwardPossible() throws Exception {
+  public void submitWithMergeIfFastForwardPossible() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
@@ -46,7 +46,7 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
+  public void submitMultipleChanges() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     // Submit a change so that the remote head advances
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 5ebcd85..ab9eed4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -18,6 +18,7 @@
 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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -33,7 +34,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
@@ -63,7 +64,7 @@
   }
 
   @Test
-  public void submitWithFastForward() throws Exception {
+  public void submitWithFastForward() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
@@ -79,7 +80,7 @@
   }
 
   @Test
-  public void submitMultipleChanges() throws Exception {
+  public void submitMultipleChanges() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
@@ -136,7 +137,7 @@
   }
 
   @Test
-  public void submitChangesAcrossRepos() throws Exception {
+  public void submitChangesAcrossRepos() throws Throwable {
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
     Project.NameKey p3 = projectOperations.newProject().create();
@@ -180,7 +181,7 @@
     approve(change3.getChangeId());
 
     // get a preview before submitting:
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
+    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
     RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -196,24 +197,24 @@
       // check that the preview matched what happened:
       assertThat(preview).hasSize(3);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
       assertTrees(p1, preview);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
       assertTrees(p2, preview);
 
-      assertThat(preview).containsKey(new Branch.NameKey(p3, "refs/heads/master"));
+      assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
       assertTrees(p3, preview);
     } else {
       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();
+      assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
   @Test
-  public void submitChangesAcrossReposBlocked() throws Exception {
+  public void submitChangesAcrossReposBlocked() throws Throwable {
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
     Project.NameKey p3 = projectOperations.newProject().create();
@@ -277,15 +278,12 @@
               + "and upload the rebased commit for review.";
 
       // Get a preview before submitting:
-      try (BinaryResult r = gApi.changes().id(change1b.getChangeId()).current().submitPreview()) {
-        // We cannot just use the ExpectedException infrastructure as provided
-        // by AbstractDaemonTest, as then we'd stop early and not test the
-        // actual submit.
+      RestApiException thrown =
+          assertThrows(
+              RestApiException.class,
+              () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
+      assertThat(thrown.getMessage()).isEqualTo(msg);
 
-        fail("expected failure");
-      } catch (RestApiException e) {
-        assertThat(e.getMessage()).isEqualTo(msg);
-      }
       submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
@@ -313,7 +311,7 @@
   }
 
   @Test
-  public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
+  public void submitWithMergedAncestorsOnOtherBranch() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     PushOneCommit.Result change1 =
@@ -362,7 +360,7 @@
   }
 
   @Test
-  public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
+  public void submitWithOpenAncestorsOnOtherBranch() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 =
         createChange(testRepo, "master", "base commit", "a.txt", "1", "");
@@ -435,7 +433,7 @@
   }
 
   @Test
-  public void gerritWorkflow() throws Exception {
+  public void gerritWorkflow() throws Throwable {
     RevCommit initialHead = getRemoteHead();
 
     // We'll setup a master and a stable branch.
@@ -493,7 +491,7 @@
   }
 
   @Test
-  public void openChangeForTargetBranchPreventsMerge() throws Exception {
+  public void openChangeForTargetBranchPreventsMerge() throws Throwable {
     gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
 
     // Propose a change for master, but leave it open for master!
@@ -517,7 +515,7 @@
         change3.getChangeId(),
         "Failed to submit 1 change due to the following problems:\n"
             + "Change "
-            + change3.getPatchSetId().getParentKey().get()
+            + change3.getPatchSetId().changeId().get()
             + ": Depends on change that was not submitted."
             + " Commit "
             + change3.getCommit().name()
@@ -532,7 +530,7 @@
   }
 
   @Test
-  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Exception {
+  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
     PushOneCommit.Result changeResult = change.to("refs/for/master");
@@ -574,7 +572,7 @@
   }
 
   @Test
-  public void dependencyOnDeletedChangePreventsMerge() throws Exception {
+  public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
     PushOneCommit.Result changeResult = change.to("refs/for/master");
@@ -608,8 +606,8 @@
   }
 
   @Test
-  public void dependencyOnChangeForNonVisibleBranchPreventsMerge() throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, REGISTERED_USERS, false);
+  public void dependencyOnChangeForNonVisibleBranchPreventsMerge() throws Throwable {
+    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", REGISTERED_USERS, false);
     grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
 
     // Create a change
@@ -624,13 +622,13 @@
 
     // Move the first change to a destination branch that is non-visible to user so that user cannot
     // this change anymore.
-    Branch.NameKey secretBranch = new Branch.NameKey(project, "secretBranch");
+    BranchNameKey secretBranch = BranchNameKey.create(project, "secretBranch");
     gApi.projects()
-        .name(secretBranch.getParentKey().get())
-        .branch(secretBranch.get())
+        .name(secretBranch.project().get())
+        .branch(secretBranch.branch())
         .create(new BranchInput());
-    gApi.changes().id(changeResult.getChangeId()).move(secretBranch.get());
-    block(secretBranch.get(), "read", ANONYMOUS_USERS);
+    gApi.changes().id(changeResult.getChangeId()).move(secretBranch.branch());
+    block(secretBranch.branch(), "read", ANONYMOUS_USERS);
 
     requestScopeOperations.setApiUser(user.id());
 
@@ -663,8 +661,8 @@
   }
 
   @Test
-  public void dependencyOnHiddenChangePreventsMerge() throws Exception {
-    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, REGISTERED_USERS, false);
+  public void dependencyOnHiddenChangePreventsMerge() throws Throwable {
+    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", REGISTERED_USERS, false);
     grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
 
     // Create a change
@@ -699,7 +697,7 @@
       assertThat(e.getMessage())
           .isEqualTo(
               "A change to be submitted with "
-                  + change2Result.getChange().getId().id
+                  + change2Result.getChange().getId().get()
                   + " is not visible");
     }
     assertRefUpdatedEvents();
@@ -707,7 +705,7 @@
   }
 
   @Test
-  public void dependencyOnHiddenChangeUsingTopicPreventsMerge() throws Exception {
+  public void dependencyOnHiddenChangeUsingTopicPreventsMerge() throws Throwable {
     // Construct a topic where a change included by topic depends on a private change that is not
     // visible to the submitting user
     // (c1) --- topic --- (c2b)
@@ -718,9 +716,9 @@
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
 
-    grantLabel("Code-Review", -2, 2, p1, "refs/heads/*", false, REGISTERED_USERS, false);
+    grantLabel("Code-Review", -2, 2, p1, "refs/heads/*", REGISTERED_USERS, false);
     grant(p1, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
-    grantLabel("Code-Review", -2, 2, p2, "refs/heads/*", false, REGISTERED_USERS, false);
+    grantLabel("Code-Review", -2, 2, p2, "refs/heads/*", REGISTERED_USERS, false);
     grant(p2, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
 
     TestRepository<?> repo1 = cloneProject(p1);
@@ -759,7 +757,7 @@
       assertThat(e.getMessage())
           .isEqualTo(
               "A change to be submitted with "
-                  + change1.getChange().getId().id
+                  + change1.getChange().getId().get()
                   + " is not visible");
     }
     assertRefUpdatedEvents();
@@ -767,7 +765,7 @@
   }
 
   @Test
-  public void testPreviewSubmitTgz() throws Exception {
+  public void testPreviewSubmitTgz() throws Throwable {
     Project.NameKey p1 = projectOperations.newProject().create();
 
     TestRepository<?> repo1 = cloneProject(p1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index eb8dea5..5d5887d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -52,7 +52,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithPossibleFastForward() throws Exception {
+  public void submitWithPossibleFastForward() throws Throwable {
     RevCommit oldHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
@@ -72,7 +72,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void alwaysAddFooters() throws Exception {
+  public void alwaysAddFooters() throws Throwable {
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
@@ -89,21 +89,21 @@
   }
 
   @Test
-  public void rebaseInvokesChangeMessageModifiers() throws Exception {
+  public void rebaseInvokesChangeMessageModifiers() throws Throwable {
     ChangeMessageModifier modifier1 =
         (msg, orig, tip, dest) -> msg + "This-change-before-rebase: " + orig.name() + "\n";
     ChangeMessageModifier modifier2 =
         (msg, orig, tip, dest) -> msg + "Previous-step-tip: " + tip.name() + "\n";
     ChangeMessageModifier modifier3 =
-        (msg, orig, tip, dest) -> msg + "Dest: " + dest.getShortName() + "\n";
+        (msg, orig, tip, dest) -> msg + "Dest: " + dest.shortName() + "\n";
 
     try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2, modifier3)) {
       ImmutableList<PushOneCommit.Result> changes = submitWithRebase(admin);
       ChangeData cd1 = changes.get(0).getChange();
       ChangeData cd2 = changes.get(1).getChange();
       assertThat(cd2.patchSets()).hasSize(2);
-      String change1CurrentCommit = cd1.currentPatchSet().getRevision().get();
-      String change2Ps1Commit = cd2.patchSet(new PatchSet.Id(cd2.getId(), 1)).getRevision().get();
+      String change1CurrentCommit = cd1.currentPatchSet().commitId().name();
+      String change2Ps1Commit = cd2.patchSet(PatchSet.id(cd2.getId(), 1)).commitId().name();
 
       assertThat(gApi.changes().id(cd2.getId().get()).revision(2).commit(false).message)
           .isEqualTo(
@@ -120,7 +120,7 @@
   }
 
   @Test
-  public void failingChangeMessageModifierShortCircuits() throws Exception {
+  public void failingChangeMessageModifierShortCircuits() throws Throwable {
     ChangeMessageModifier modifier1 =
         (msg, orig, tip, dest) -> {
           throw new IllegalStateException("boom");
@@ -139,7 +139,7 @@
   }
 
   @Test
-  public void changeMessageModifierReturningNullShortCircuits() throws Exception {
+  public void changeMessageModifierReturningNullShortCircuits() throws Throwable {
     ChangeMessageModifier modifier1 = (msg, orig, tip, dest) -> null;
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (AutoCloseable ignored = installChangeMessageModifiers(modifier1, modifier2)) {
@@ -171,14 +171,14 @@
     };
   }
 
-  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Exception {
+  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Throwable {
     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 {
+  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Throwable {
     testRepo.git().fetch().setRemote("origin").call();
     ChangeInfo info = get(change.getChangeId(), CURRENT_REVISION);
     RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index 7bb31a0..1b71a2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -32,7 +32,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithFastForward() throws Exception {
+  public void submitWithFastForward() throws Throwable {
     RevCommit oldHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
@@ -50,7 +50,7 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
+  public void submitWithContentMerge() throws Throwable {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index 87fc9f6..78349f5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -186,8 +186,8 @@
     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(Project.nameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(Project.nameKey(project2Name));
 
     PushOneCommit.Result a = createChange(project1, "A");
     PushOneCommit.Result b =
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 3d987a3..bea1748 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.createAnnotatedTag;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
@@ -217,7 +217,7 @@
             ? pushHead(testRepo, tagRef, false, force)
             : GitUtil.pushTag(testRepo, tagName, !createTag);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
     return tagName;
   }
 
@@ -225,7 +225,7 @@
     String tagRef = tagRef(tagName);
     PushResult r = deleteRef(testRepo, tagRef);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
   }
 
   private void allowTagCreation() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 72af075..503ebcc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -17,6 +17,8 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
@@ -143,8 +145,7 @@
   @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
-    exception.expect(BadRequestException.class);
-    pApi().accessChange(accessInput);
+    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
   }
 
   @Test
@@ -326,8 +327,7 @@
     pApi().access(accessInput);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    pApi().access();
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
   }
 
   @Test
@@ -346,8 +346,7 @@
     accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    pApi().access();
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
   }
 
   @Test
@@ -409,9 +408,8 @@
     accessInput.parent = newParentProjectName;
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    exception.expectMessage("administrate server not permitted");
-    pApi().access(accessInput);
+    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
   }
 
   @Test
@@ -436,8 +434,8 @@
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -465,8 +463,7 @@
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
-    exception.expect(BadRequestException.class);
-    pApi().access(accessInput);
+    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
   }
 
   @Test
@@ -479,9 +476,9 @@
     accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    exception.expect(BadRequestException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -492,8 +489,8 @@
     accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
   }
 
   @Test
@@ -572,7 +569,7 @@
             .file(ProjectConfig.PROJECT_CONFIG)
             .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
 
     // Make permission change through API
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -591,16 +588,20 @@
             .file(ProjectConfig.PROJECT_CONFIG)
             .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
   }
 
   @Test
   public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     accessInput.parent = project.get();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(allUsers.get() + " must inherit from " + allProjects.get());
-    gApi.projects().name(allUsers.get()).access(accessInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allUsers.get()).access(accessInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(allUsers.get() + " must inherit from " + allProjects.get());
   }
 
   @Test
@@ -650,9 +651,9 @@
     String invalidRef = Constants.R_HEADS + "stable_*";
     accessInput.add.put(invalidRef, accessSectionInfo);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid Name: " + invalidRef);
-    pApi().access(accessInput);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
   }
 
   @Test
@@ -664,9 +665,9 @@
     String invalidRef = Constants.R_HEADS + "stable_*";
     accessInput.add.put(invalidRef, accessSectionInfo);
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid Name: " + invalidRef);
-    pApi().accessChange(accessInput);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
   }
 
   private ProjectApi pApi() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index 7667fc0..10e3e99 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.MergeableInfo;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
@@ -34,15 +34,12 @@
 
 public class CheckMergeabilityIT extends AbstractDaemonTest {
 
-  private Branch.NameKey branch;
+  private BranchNameKey branch;
 
   @Before
   public void setUp() throws Exception {
-    branch = new Branch.NameKey(project, "test");
-    gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
-        .create(new BranchInput());
+    branch = BranchNameKey.create(project, "test");
+    gApi.projects().name(branch.project().get()).branch(branch.branch()).create(new BranchInput());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index c06ec69..96ad91c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
@@ -32,7 +33,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 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.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.inject.Inject;
 import org.junit.Before;
@@ -41,11 +42,11 @@
 public class CreateBranchIT extends AbstractDaemonTest {
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private Branch.NameKey testBranch;
+  private BranchNameKey testBranch;
 
   @Before
   public void setUp() throws Exception {
-    testBranch = new Branch.NameKey(project, "test");
+    testBranch = BranchNameKey.create(project, "test");
   }
 
   @Test
@@ -104,7 +105,7 @@
     String metaRef = RefNames.REFS_META + "foo";
     allow(metaRef, Permission.CREATE, REGISTERED_USERS);
     allow(metaRef, Permission.PUSH, REGISTERED_USERS);
-    assertCreateSucceeds(new Branch.NameKey(project, metaRef));
+    assertCreateSucceeds(BranchNameKey.create(project, metaRef));
   }
 
   @Test
@@ -112,7 +113,7 @@
     allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
     allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
     assertCreateFails(
-        new Branch.NameKey(allUsers, RefNames.refsUsers(new Account.Id(1))),
+        BranchNameKey.create(allUsers, RefNames.refsUsers(Account.id(1))),
         RefNames.refsUsers(admin.id()),
         ResourceConflictException.class,
         "Not allowed to create user branch.");
@@ -123,7 +124,7 @@
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
     assertCreateFails(
-        new Branch.NameKey(allUsers, RefNames.refsGroups(new AccountGroup.UUID("foo"))),
+        BranchNameKey.create(allUsers, RefNames.refsGroups(AccountGroup.uuid("foo"))),
         RefNames.refsGroups(adminGroupUuid()),
         ResourceConflictException.class,
         "Not allowed to create group branch.");
@@ -137,37 +138,36 @@
     allow("refs/*", Permission.OWNER, REGISTERED_USERS);
   }
 
-  private BranchApi branch(Branch.NameKey branch) throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  private BranchApi branch(BranchNameKey branch) throws Exception {
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 
-  private void assertCreateSucceeds(Branch.NameKey branch) throws Exception {
+  private void assertCreateSucceeds(BranchNameKey branch) throws Exception {
     BranchInfo created = branch(branch).create(new BranchInput()).get();
-    assertThat(created.ref).isEqualTo(branch.get());
+    assertThat(created.ref).isEqualTo(branch.branch());
   }
 
   private void assertCreateFails(
-      Branch.NameKey branch, Class<? extends RestApiException> errType, String errMsg)
+      BranchNameKey branch, Class<? extends RestApiException> errType, String errMsg)
       throws Exception {
     assertCreateFails(branch, null, errType, errMsg);
   }
 
   private void assertCreateFails(
-      Branch.NameKey branch,
+      BranchNameKey branch,
       String revision,
       Class<? extends RestApiException> errType,
       String errMsg)
       throws Exception {
     BranchInput in = new BranchInput();
     in.revision = revision;
+    RestApiException thrown = assertThrows(errType, () -> branch(branch).create(in));
     if (errMsg != null) {
-      exception.expectMessage(errMsg);
+      assertThat(thrown).hasMessageThat().contains(errMsg);
     }
-    exception.expect(errType);
-    branch(branch).create(in);
   }
 
-  private void assertCreateFails(Branch.NameKey branch, Class<? extends RestApiException> errType)
+  private void assertCreateFails(BranchNameKey branch, Class<? extends RestApiException> errType)
       throws Exception {
     assertCreateFails(branch, errType, null);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index d8fbbe5..894d79f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
 import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -86,7 +87,7 @@
     // for more extensive coverage of the LabelTypeInfo.
     assertThat(p.labels).hasSize(1);
 
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -162,7 +163,7 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -175,7 +176,7 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + ".git").get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -186,7 +187,7 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + "/").get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -197,7 +198,7 @@
     String newProjectName = name("newProject/newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
@@ -216,7 +217,7 @@
     in.requireChangeId = InheritableBoolean.TRUE;
     ProjectInfo p = gApi.projects().create(in).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    Project project = projectCache.get(new Project.NameKey(newProjectName)).getProject();
+    Project project = projectCache.get(Project.nameKey(newProjectName)).getProject();
     assertProjectInfo(project, p);
     assertThat(project.getDescription()).isEqualTo(in.description);
     assertThat(project.getConfiguredSubmitType()).isEqualTo(in.submitType);
@@ -242,7 +243,7 @@
     in.name = childName;
     in.parent = parentName;
     gApi.projects().create(in);
-    Project project = projectCache.get(new Project.NameKey(childName)).getProject();
+    Project project = projectCache.get(Project.nameKey(childName)).getProject();
     assertThat(project.getParentName()).isEqualTo(in.parent);
   }
 
@@ -265,12 +266,12 @@
     in.owners.add(
         Integer.toString(
             groupCache
-                .get(new AccountGroup.NameKey("Administrators"))
+                .get(AccountGroup.nameKey("Administrators"))
                 .orElse(null)
                 .getId()
                 .get())); // by ID
     gApi.projects().create(in);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
     expectedOwnerIds.add(SystemGroupBackend.ANONYMOUS_USERS);
     expectedOwnerIds.add(SystemGroupBackend.REGISTERED_USERS);
@@ -447,13 +448,13 @@
   }
 
   private void assertHead(String projectName, String expectedRef) throws Exception {
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
       assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
     }
   }
 
   private void assertEmptyCommit(String projectName, String... refs) throws Exception {
-    Project.NameKey projectKey = new Project.NameKey(projectName);
+    Project.NameKey projectKey = Project.nameKey(projectName);
     try (Repository repo = repoManager.openRepository(projectKey);
         RevWalk rw = new RevWalk(repo);
         TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
@@ -469,12 +470,11 @@
 
   private void assertCreateFails(ProjectInput in, Class<? extends RestApiException> errType)
       throws Exception {
-    exception.expect(errType);
-    gApi.projects().create(in);
+    assertThrows(errType, () -> gApi.projects().create(in));
   }
 
   private Optional<String> readProjectConfig(String projectName) throws Exception {
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
       TestRepository<?> tr = new TestRepository<>(repo);
       RevWalk rw = tr.getRevWalk();
       Ref ref = repo.exactRef(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 8c96662..f95342a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -17,11 +17,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.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchApi;
@@ -30,7 +32,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.inject.Inject;
 import org.junit.Before;
@@ -40,12 +42,12 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private Branch.NameKey testBranch;
+  private BranchNameKey testBranch;
 
   @Before
   public void setUp() throws Exception {
     project = projectOperations.newProject().create();
-    testBranch = new Branch.NameKey(project, "test");
+    testBranch = BranchNameKey.create(project, "test");
     branch(testBranch).create(new BranchInput());
   }
 
@@ -98,7 +100,7 @@
   @Test
   public void deleteBranchByRestWithoutRefsHeadsPrefix() throws Exception {
     grantDelete();
-    String ref = testBranch.getShortName();
+    String ref = testBranch.shortName();
     assertThat(ref).doesNotMatch(R_HEADS);
     assertDeleteByRestSucceeds(testBranch, ref);
   }
@@ -106,14 +108,14 @@
   @Test
   public void deleteBranchByRestWithFullName() throws Exception {
     grantDelete();
-    assertDeleteByRestSucceeds(testBranch, testBranch.get());
+    assertDeleteByRestSucceeds(testBranch, testBranch.branch());
   }
 
   @Test
   public void deleteBranchByRestFailsWithUnencodedFullName() throws Exception {
     grantDelete();
     RestResponse r =
-        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.get());
+        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.branch());
     r.assertNotFound();
     branch(testBranch).get();
   }
@@ -124,7 +126,7 @@
     allow(metaRef, Permission.CREATE, REGISTERED_USERS);
     allow(metaRef, Permission.PUSH, REGISTERED_USERS);
 
-    Branch.NameKey metaBranch = new Branch.NameKey(project, metaRef);
+    BranchNameKey metaBranch = BranchNameKey.create(project, metaRef);
     branch(metaBranch).create(new BranchInput());
 
     grantDelete();
@@ -133,12 +135,24 @@
 
   @Test
   public void deleteUserBranch_Conflict() throws Exception {
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
-    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allow(Permission.CREATE)
+                .ref(RefNames.REFS_USERS + "*")
+                .group(REGISTERED_USERS))
+        .add(
+            TestProjectUpdate.allow(Permission.PUSH)
+                .ref(RefNames.REFS_USERS + "*")
+                .group(REGISTERED_USERS))
+        .update();
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Not allowed to delete user branch.");
-    branch(new Branch.NameKey(allUsers, RefNames.refsUsers(admin.id()))).delete();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> branch(BranchNameKey.create(allUsers, RefNames.refsUsers(admin.id()))).delete());
+    assertThat(thrown).hasMessageThat().contains("Not allowed to delete user branch.");
   }
 
   @Test
@@ -146,13 +160,25 @@
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
 
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Not allowed to delete group branch.");
-    branch(new Branch.NameKey(allUsers, RefNames.refsGroups(adminGroupUuid()))).delete();
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                branch(BranchNameKey.create(allUsers, RefNames.refsGroups(adminGroupUuid())))
+                    .delete());
+    assertThat(thrown).hasMessageThat().contains("Not allowed to delete group branch.");
   }
 
   private void blockForcePush() throws Exception {
-    block("refs/heads/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.block(Permission.PUSH)
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .force(true))
+        .update();
   }
 
   private void grantForcePush() throws Exception {
@@ -167,11 +193,11 @@
     allow("refs/*", Permission.OWNER, REGISTERED_USERS);
   }
 
-  private BranchApi branch(Branch.NameKey branch) throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  private BranchApi branch(BranchNameKey branch) throws Exception {
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 
-  private void assertDeleteByRestSucceeds(Branch.NameKey branch, String ref) throws Exception {
+  private void assertDeleteByRestSucceeds(BranchNameKey branch, String ref) throws Exception {
     RestResponse r =
         userRestSession.delete(
             "/projects/"
@@ -179,24 +205,21 @@
                 + "/branches/"
                 + IdString.fromDecoded(ref).encoded());
     r.assertNoContent();
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
+    assertThrows(ResourceNotFoundException.class, () -> branch(branch).get());
   }
 
-  private void assertDeleteSucceeds(Branch.NameKey branch) throws Exception {
+  private void assertDeleteSucceeds(BranchNameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isTrue();
     String branchRev = branch(branch).get().revision;
     branch(branch).delete();
     eventRecorder.assertRefUpdatedEvents(
-        project.get(), branch.get(), null, branchRev, branchRev, null);
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
+        project.get(), branch.branch(), null, branchRev, branchRev, null);
+    assertThrows(ResourceNotFoundException.class, () -> branch(branch).get());
   }
 
-  private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
+  private void assertDeleteForbidden(BranchNameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isNull();
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: delete");
-    branch(branch).delete();
+    AuthException thrown = assertThrows(AuthException.class, () -> branch(branch).delete());
+    assertThat(thrown).hasMessageThat().contains("not permitted: delete");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index 47b6a78..f640c7c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
@@ -139,26 +140,26 @@
   @Test
   public void missingInput() throws Exception {
     DeleteBranchesInput input = null;
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   @Test
   public void missingBranchList() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   @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);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> project().deleteBranches(input));
+    assertThat(thrown).hasMessageThat().contains("branches must be specified");
   }
 
   private String errorMessageForBranches(List<String> branches) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 07bb2b1..892c375 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -17,10 +17,12 @@
 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 com.google.gerrit.testing.GerritJUnit.assertThrows;
 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.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.TagApi;
@@ -97,7 +99,15 @@
   }
 
   private void blockForcePush() throws Exception {
-    block("refs/tags/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.block(Permission.PUSH)
+                .ref("refs/tags/*")
+                .group(ANONYMOUS_USERS)
+                .force(true))
+        .update();
   }
 
   private void grantForcePush() throws Exception {
@@ -122,14 +132,12 @@
     String tagRev = tagInfo.revision;
     tag().delete();
     eventRecorder.assertRefUpdatedEvents(project.get(), TAG, null, tagRev, tagRev, null);
-    exception.expect(ResourceNotFoundException.class);
-    tag().get();
+    assertThrows(ResourceNotFoundException.class, () -> tag().get());
   }
 
   private void assertDeleteForbidden() throws Exception {
     assertThat(tag().get().canDelete).isNull();
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: delete");
-    tag().delete();
+    AuthException thrown = assertThrows(AuthException.class, () -> tag().delete());
+    assertThat(thrown).hasMessageThat().contains("not permitted: delete");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index 63f41ad..e63b28bc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -22,18 +23,18 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class FileBranchIT extends AbstractDaemonTest {
 
-  private Branch.NameKey branch;
+  private BranchNameKey branch;
 
   @Before
   public void setUp() throws Exception {
-    branch = new Branch.NameKey(project, "master");
+    branch = BranchNameKey.create(project, "master");
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
     revision(change).submit();
@@ -45,12 +46,12 @@
     assertThat(content.asString()).isEqualTo(PushOneCommit.FILE_CONTENT);
   }
 
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getNonExistingFile() throws Exception {
-    branch().file("does-not-exist");
+    assertThrows(ResourceNotFoundException.class, () -> branch().file("does-not-exist"));
   }
 
   private BranchApi branch() throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+    return gApi.projects().name(branch.project().get()).branch(branch.branch());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
index d736578..7e45e02 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -69,8 +71,10 @@
   }
 
   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();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(parent.get()).child(child).get());
+    assertThat(thrown).hasMessageThat().contains(child);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index 989050c..e9aa589 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -54,8 +55,9 @@
     assertThat(p.name).isEqualTo(name);
   }
 
-  @Test(expected = ResourceNotFoundException.class)
+  @Test
   public void getProjectNotExisting() throws Exception {
-    gApi.projects().name("does-not-exist").get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name("does-not-exist").get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index ec1c708..d1364f0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -35,16 +36,18 @@
 
   @Test
   public void listBranchesOfNonExistingProject_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("non-existing").branches().get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name("non-existing").branches().get());
   }
 
   @Test
   public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
     blockRead("refs/*");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).branches().get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).branches().get());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 7746820..37b01a5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -31,9 +33,11 @@
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("non-existing");
-    gApi.projects().name(name("non-existing")).child("children");
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(name("non-existing")).child("children"));
+    assertThat(thrown).hasMessageThat().contains("non-existing");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index d9b8e03..f29069c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -240,7 +240,7 @@
     int n = 5;
     assertThat(all).hasSize(n);
     assertThatNameList(gApi.projects().list().withPrefix(pre).withStart(n - 1).get())
-        .containsExactly(new Project.NameKey(Iterables.getLast(all).name));
+        .containsExactly(Project.nameKey(Iterables.getLast(all).name));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
index 3b5a3a4..45f59e9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
@@ -38,7 +38,7 @@
           .that(Url.decode(info.id))
           .isEqualTo(info.name);
     }
-    return assertThat(Iterables.transform(actual, p -> new Project.NameKey(p.name)));
+    return assertThat(Iterables.transform(actual, p -> Project.nameKey(p.name)));
   }
 
   public static void assertProjectInfo(Project project, ProjectInfo info) {
@@ -47,7 +47,7 @@
       assertThat(info.name).isEqualTo(project.getName());
     }
     assertThat(Url.decode(info.id)).isEqualTo(project.getName());
-    Project.NameKey parentName = project.getParent(new Project.NameKey("All-Projects"));
+    Project.NameKey parentName = project.getParent(Project.nameKey("All-Projects"));
     if (parentName != null) {
       assertThat(info.parent).isEqualTo(parentName.get());
     } else {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
index b3e3d2f..a93fc0f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.api.projects.RefInfo;
@@ -38,10 +39,12 @@
   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);
+      assertWithMessage("revision of " + actual.ref)
+          .that(actual.revision)
+          .isEqualTo(expected.revision);
     }
-    assertThat(toBoolean(actual.canDelete))
-        .named("can delete " + actual.ref)
+    assertWithMessage("can delete " + actual.ref)
+        .that(toBoolean(actual.canDelete))
         .isEqualTo(toBoolean(expected.canDelete));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 0165ead..2bd9460 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.FluentIterable;
@@ -62,29 +63,31 @@
 
   @Test
   public void listTagsOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tags().get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name("does-not-exist").tags().get());
   }
 
   @Test
   public void getTagOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tag("tag").get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name("does-not-exist").tag("tag").get());
   }
 
   @Test
   public void listTagsOfNonVisibleProject() throws Exception {
     blockRead("refs/*");
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tags().get();
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.projects().name(project.get()).tags().get());
   }
 
   @Test
   public void getTagOfNonVisibleProject() throws Exception {
     blockRead("refs/*");
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tag("tag").get();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).tag("tag").get());
   }
 
   @Test
@@ -247,9 +250,9 @@
     assertThat(result.ref).isEqualTo(R_TAGS + "test");
 
     input.ref = "refs/tags/test";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("tag \"" + R_TAGS + "test\" already exists");
-    tag(input.ref).create(input);
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("tag \"" + R_TAGS + "test\" already exists");
   }
 
   @Test
@@ -257,9 +260,8 @@
     block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
     TagInput input = new TagInput();
     input.ref = "test";
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted: create");
-    tag(input.ref).create(input);
+    AuthException thrown = assertThrows(AuthException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("not permitted: create");
   }
 
   @Test
@@ -268,9 +270,10 @@
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
-    exception.expect(AuthException.class);
-    exception.expectMessage("Cannot create annotated tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
+    AuthException thrown = assertThrows(AuthException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create annotated tag \"" + R_TAGS + "test\"");
   }
 
   @Test
@@ -278,9 +281,9 @@
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = SIGNED_ANNOTATION;
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot create signed tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
+    MethodNotAllowedException thrown =
+        assertThrows(MethodNotAllowedException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("Cannot create signed tag \"" + R_TAGS + "test\"");
   }
 
   @Test
@@ -288,9 +291,9 @@
     TagInput input = new TagInput();
     input.ref = "test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("ref must match URL");
-    tag("TEST").create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag("TEST").create(input));
+    assertThat(thrown).hasMessageThat().contains("ref must match URL");
   }
 
   @Test
@@ -300,9 +303,9 @@
     TagInput input = new TagInput();
     input.ref = "refs/heads/test";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"" + input.ref + "\"");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid tag name \"" + input.ref + "\"");
   }
 
   @Test
@@ -312,9 +315,9 @@
     TagInput input = new TagInput();
     input.ref = "//";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"refs/tags/\"");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid tag name \"refs/tags/\"");
   }
 
   @Test
@@ -325,9 +328,9 @@
     input.ref = "test";
     input.revision = "abcdefg";
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid base revision");
-    tag(input.ref).create(input);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown).hasMessageThat().contains("Invalid base revision");
   }
 
   private void assertTagList(FluentIterable<String> expected, List<TagInfo> actual)
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index fa1b467..2832ee5 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth8.assertThat;
 
@@ -123,7 +124,7 @@
     Account.Id idWithExistingIdAsFullname =
         accountOperations.newAccount().fullname(existingId.toString()).create();
 
-    Account.Id nonexistentId = new Account.Id(sequences.nextAccountId());
+    Account.Id nonexistentId = Account.id(sequences.nextAccountId());
     accountOperations.newAccount().fullname(nonexistentId.toString()).create();
 
     assertThat(resolve(existingId)).containsExactly(existingId);
@@ -137,7 +138,7 @@
     Account.Id existingId = accountOperations.newAccount().fullname("Test User").create();
     accountOperations.newAccount().fullname(existingId.toString()).create();
 
-    Account.Id nonexistentId = new Account.Id(sequences.nextAccountId());
+    Account.Id nonexistentId = Account.id(sequences.nextAccountId());
     accountOperations.newAccount().fullname("Any Name (" + nonexistentId + ")").create();
     accountOperations.newAccount().fullname(nonexistentId.toString()).create();
 
@@ -259,14 +260,14 @@
 
     assertThat(resolve(account.accountId())).containsExactly(id);
     for (String input : inputs) {
-      assertThat(resolve(input)).named("results for %s (active)", input).containsExactly(id);
+      assertWithMessage("results for %s (active)", input).that(resolve(input)).containsExactly(id);
     }
 
     gApi.accounts().id(id.get()).setActive(false);
     assertThat(resolve(account.accountId())).containsExactly(id);
     for (String input : inputs) {
       Result result = accountResolver.resolve(input);
-      assertThat(result.asIdSet()).named("results for %s (inactive)", input).isEmpty();
+      assertWithMessage("results for %s (inactive)", input).that(result.asIdSet()).isEmpty();
       try {
         result.asUnique();
         assert_().fail("expected UnresolvableAccountException");
@@ -282,8 +283,8 @@
                     + ": "
                     + nameEmail);
       }
-      assertThat(resolveByNameOrEmail(input))
-          .named("results by name or email for %s (inactive)", input)
+      assertWithMessage("results by name or email for %s (inactive)", input)
+          .that(resolveByNameOrEmail(input))
           .isEmpty();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 15dd3fb..a2a6f09 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -18,10 +18,10 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
@@ -64,6 +64,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -94,8 +95,9 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    exception.expect(ResourceNotFoundException.class);
-    getPublishedComment(changeId, revId, "non-existing");
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> getPublishedComment(changeId, revId, "non-existing"));
   }
 
   @Test
@@ -131,8 +133,7 @@
       addDraft(changeId, revId, c4);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
-      assertThat(Lists.transform(result.get(path), infoToDraft(path)))
-          .containsExactly(c1, c2, c3, c4);
+      assertThat(result.get(path).stream().map(infoToDraft(path))).containsExactly(c1, c2, c3, c4);
     }
   }
 
@@ -233,8 +234,7 @@
       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, c4);
+      assertThat(result.get(file).stream().map(infoToInput(file))).containsExactly(c1, c2, c3, c4);
     }
 
     // for the commit message comments on the auto-merge are not possible
@@ -252,7 +252,7 @@
       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);
+      assertThat(result.get(file).stream().map(infoToInput(file))).containsExactly(c1, c2, c3);
     }
   }
 
@@ -263,9 +263,11 @@
     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);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> revision(r).review(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
   }
 
   @Test
@@ -291,7 +293,7 @@
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToInput(file)))
+    assertThat(actualComments.stream().map(infoToInput(file)))
         .containsExactlyElementsIn(expectedComments);
   }
 
@@ -338,7 +340,7 @@
     Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToDraft(file)))
+    assertThat(actualComments.stream().map(infoToDraft(file)))
         .containsExactlyElementsIn(expectedDrafts);
   }
 
@@ -769,8 +771,9 @@
     DeleteCommentInput input = new DeleteCommentInput("contains confidential information");
 
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input);
+    assertThrows(
+        AuthException.class,
+        () -> gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 7c375bd..b1a2ed0 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -131,7 +131,7 @@
     assertProblems(
         notes,
         null,
-        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Ref missing: " + ps.id().toRefName()),
         problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
   }
 
@@ -142,7 +142,7 @@
     PatchSet ps = insertMissingPatchSet(notes, rev);
     notes = reload(notes);
 
-    String refName = ps.getId().toRefName();
+    String refName = ps.id().toRefName();
     assertProblems(
         notes,
         new FixInput(),
@@ -153,8 +153,7 @@
   @Test
   public void patchSetRefMissing() throws Exception {
     ChangeNotes notes = insertChange();
-    serverSideTestRepo.update(
-        "refs/other/foo", ObjectId.fromString(psUtil.current(notes).getRevision().get()));
+    serverSideTestRepo.update("refs/other/foo", psUtil.current(notes).commitId());
     String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
@@ -164,15 +163,15 @@
   @Test
   public void patchSetRefMissingWithFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
-    serverSideTestRepo.update("refs/other/foo", ObjectId.fromString(rev));
+    ObjectId commitId = psUtil.current(notes).commitId();
+    serverSideTestRepo.update("refs/other/foo", commitId);
     String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
     assertProblems(
         notes, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
-    assertThat(serverSideTestRepo.getRepository().exactRef(refName).getObjectId().name())
-        .isEqualTo(rev);
+    assertThat(serverSideTestRepo.getRepository().exactRef(refName).getObjectId())
+        .isEqualTo(commitId);
   }
 
   @Test
@@ -189,13 +188,13 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Ref missing: " + ps2.id().toRefName()),
         problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
-    assertThat(psUtil.get(notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(notes, ps2.getId())).isNull();
+    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps2.id())).isNull();
   }
 
   @Test
@@ -218,17 +217,17 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Ref missing: " + ps2.id().toRefName()),
         problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"),
-        problem("Ref missing: " + ps4.getId().toRefName()),
+        problem("Ref missing: " + ps4.id().toRefName()),
         problem("Object missing: patch set 4: " + rev4, FIXED, "Deleted patch set"));
 
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(3);
-    assertThat(psUtil.get(notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(notes, ps2.getId())).isNull();
-    assertThat(psUtil.get(notes, ps3.getId())).isNotNull();
-    assertThat(psUtil.get(notes, ps4.getId())).isNull();
+    assertThat(psUtil.get(notes, ps1.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps2.id())).isNull();
+    assertThat(psUtil.get(notes, ps3.id())).isNotNull();
+    assertThat(psUtil.get(notes, ps4.id())).isNull();
   }
 
   @Test
@@ -245,7 +244,7 @@
             + "\n"
             + "Patch-set: 1\n"
             + "Branch: "
-            + c.getDest().get()
+            + c.getDest().branch()
             + "\n"
             + "Change-id: "
             + c.getKey().get()
@@ -265,7 +264,7 @@
     assertProblems(
         notes,
         fix,
-        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Ref missing: " + ps.id().toRefName()),
         problem(
             "Object missing: patch set 1: " + rev,
             FIX_FAILED,
@@ -280,13 +279,13 @@
   public void duplicatePatchSetRevisions() throws Exception {
     ChangeNotes notes = insertChange();
     PatchSet ps1 = psUtil.current(notes);
-    String rev = ps1.getRevision().get();
 
-    notes =
-        incrementPatchSet(
-            notes, serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+    notes = incrementPatchSet(notes, serverSideTestRepo.getRevWalk().parseCommit(ps1.commitId()));
 
-    assertProblems(notes, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
+    assertProblems(
+        notes,
+        null,
+        problem("Multiple patch sets pointing to " + ps1.commitId().name() + ": [1, 2]"));
   }
 
   @Test
@@ -322,14 +321,13 @@
     }
     notes = reload(notes);
 
-    String rev = psUtil.current(notes).getRevision().get();
     ObjectId tip = getDestRef(notes);
     assertProblems(
         notes,
         null,
         problem(
             "Patch set 1 ("
-                + rev
+                + psUtil.current(notes).commitId().name()
                 + ") is not merged into destination ref"
                 + " refs/heads/master ("
                 + tip.name()
@@ -339,40 +337,40 @@
   @Test
   public void newChangeIsMerged() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     assertProblems(
         notes,
         null,
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW"));
   }
 
   @Test
   public void newChangeIsMergedWithFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     assertProblems(
         notes,
         new FixInput(),
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW",
             FIXED,
             "Marked change as merged"));
@@ -385,10 +383,10 @@
   @Test
   public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     ChangeInfo info = gApi.changes().id(notes.getChangeId().get()).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
@@ -400,22 +398,22 @@
   @Test
   public void expectedMergedCommitIsLatestPatchSet() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
+    ObjectId commitId = psUtil.current(notes).commitId();
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev;
+    fix.expectMergedAs = commitId.name();
     assertProblems(
         notes,
         fix,
         problem(
             "Patch set 1 ("
-                + rev
+                + commitId.name()
                 + ") is merged into destination ref"
                 + " refs/heads/master ("
-                + rev
+                + commitId.name()
                 + "), but change status is NEW",
             FIXED,
             "Marked change as merged"));
@@ -428,9 +426,9 @@
   @Test
   public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
     ChangeNotes notes = insertChange();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-    serverSideTestRepo.branch(notes.getChange().getDest().get()).update(commit);
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit);
 
     FixInput fix = new FixInput();
     RevCommit other = serverSideTestRepo.commit().message(commit.getFullMessage()).create();
@@ -450,9 +448,9 @@
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    String dest = notes.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
 
     RevCommit mergedAs =
         serverSideTestRepo
@@ -481,9 +479,9 @@
             "Inserted as patch set 2"));
 
     notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);
 
     assertNoProblems(notes, null);
   }
@@ -491,9 +489,9 @@
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    String dest = notes.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
 
     RevCommit mergedAs =
         serverSideTestRepo
@@ -529,9 +527,9 @@
             "Inserted as patch set 2"));
 
     notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(mergedAs);
 
     assertNoProblems(notes, null);
   }
@@ -539,41 +537,43 @@
   @Test
   public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
     ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(notes);
-    String rev1 = ps1.getRevision().get();
+    ObjectId commitId1 = psUtil.current(notes).commitId();
     notes = incrementPatchSet(notes);
     PatchSet ps2 = psUtil.current(notes);
     serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
+        .branch(notes.getChange().getDest().branch())
+        .update(serverSideTestRepo.getRevWalk().parseCommit(commitId1));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev1;
+    fix.expectMergedAs = commitId1.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev1, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commitId1.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev1
+                + commitId1.name()
                 + " corresponds to patch set 1,"
                 + " not the current patch set 2",
             FIXED,
             "Deleted patch set"),
         problem(
             "Expected merge commit "
-                + rev1
+                + commitId1.name()
                 + " corresponds to patch set 1,"
                 + " not the current patch set 2",
             FIXED,
             "Inserted as patch set 3"));
 
     notes = reload(notes);
-    PatchSet.Id psId3 = new PatchSet.Id(notes.getChangeId(), 3);
+    PatchSet.Id psId3 = PatchSet.id(notes.getChangeId(), 3);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId3);
     assertThat(notes.getChange().isMerged()).isTrue();
-    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps2.getId(), psId3);
-    assertThat(psUtil.get(notes, psId3).getRevision().get()).isEqualTo(rev1);
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps2.id(), psId3);
+    assertThat(psUtil.get(notes, psId3).commitId()).isEqualTo(commitId1);
   }
 
   @Test
@@ -582,47 +582,46 @@
     PatchSet ps1 = psUtil.current(notes);
 
     // Create dangling ref so next ID in the database becomes 3.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
     serverSideTestRepo.branch(psId2.toRefName()).update(commit2);
 
     notes = incrementPatchSet(notes);
     PatchSet ps3 = psUtil.current(notes);
-    assertThat(ps3.getId().get()).isEqualTo(3);
+    assertThat(ps3.id().get()).isEqualTo(3);
 
-    serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
+    fix.expectMergedAs = commit2.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commit2.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 3",
             FIXED,
             "Deleted patch set"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 3",
             FIXED,
             "Inserted as patch set 4"));
 
     notes = reload(notes);
-    PatchSet.Id psId4 = new PatchSet.Id(notes.getChangeId(), 4);
+    PatchSet.Id psId4 = PatchSet.id(notes.getChangeId(), 4);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId4);
     assertThat(notes.getChange().isMerged()).isTrue();
-    assertThat(psUtil.byChangeAsMap(notes).keySet())
-        .containsExactly(ps1.getId(), ps3.getId(), psId4);
-    assertThat(psUtil.get(notes, psId4).getRevision().get()).isEqualTo(rev2);
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), ps3.id(), psId4);
+    assertThat(psUtil.get(notes, psId4).commitId()).isEqualTo(commit2);
   }
 
   @Test
@@ -631,24 +630,24 @@
     PatchSet ps1 = psUtil.current(notes);
 
     // Create dangling ref with no patch set.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    PatchSet.Id psId2 = PatchSet.id(notes.getChangeId(), 2);
     RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
     serverSideTestRepo.branch(psId2.toRefName()).update(commit2);
 
-    serverSideTestRepo
-        .branch(notes.getChange().getDest().get())
-        .update(serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+    serverSideTestRepo.branch(notes.getChange().getDest().branch()).update(commit2);
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
+    fix.expectMergedAs = commit2.name();
     assertProblems(
         notes,
         fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "No patch set found for merged commit " + commit2.name(),
+            FIXED,
+            "Marked change as merged"),
         problem(
             "Expected merge commit "
-                + rev2
+                + commit2.name()
                 + " corresponds to patch set 2,"
                 + " not the current patch set 1",
             FIXED,
@@ -657,17 +656,17 @@
     notes = reload(notes);
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
     assertThat(notes.getChange().isMerged()).isTrue();
-    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.getId(), psId2);
-    assertThat(psUtil.get(notes, psId2).getRevision().get()).isEqualTo(rev2);
+    assertThat(psUtil.byChangeAsMap(notes).keySet()).containsExactly(ps1.id(), psId2);
+    assertThat(psUtil.get(notes, psId2).commitId()).isEqualTo(commit2);
   }
 
   @Test
   public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
     ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
+    String dest = notes.getChange().getDest().branch();
     RevCommit parent = serverSideTestRepo.branch(dest).commit().message("parent").create();
-    String rev = psUtil.current(notes).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes).commitId());
     serverSideTestRepo.branch(dest).update(commit);
 
     String badId = "I0000000000000000000000000000000000000000";
@@ -700,19 +699,19 @@
   @Test
   public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
     ChangeNotes notes1 = insertChange();
-    PatchSet.Id psId1 = psUtil.current(notes1).getId();
-    String dest = notes1.getChange().getDest().get();
-    String rev = psUtil.current(notes1).getRevision().get();
-    RevCommit commit = serverSideTestRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    PatchSet.Id psId1 = psUtil.current(notes1).id();
+    String dest = notes1.getChange().getDest().branch();
+    RevCommit commit =
+        serverSideTestRepo.getRevWalk().parseCommit(psUtil.current(notes1).commitId());
     serverSideTestRepo.branch(dest).update(commit);
 
     ChangeNotes notes2 = insertChange();
     notes2 = incrementPatchSet(notes2, commit);
-    PatchSet.Id psId2 = psUtil.current(notes2).getId();
+    PatchSet.Id psId2 = psUtil.current(notes2).id();
 
     ChangeNotes notes3 = insertChange();
     notes3 = incrementPatchSet(notes3, commit);
-    PatchSet.Id psId3 = psUtil.current(notes3).getId();
+    PatchSet.Id psId3 = psUtil.current(notes3).id();
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = commit.name();
@@ -744,10 +743,10 @@
   }
 
   private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
-    Change.Id id = new Change.Id(sequences.nextChangeId());
+    Change.Id id = Change.id(sequences.nextChangeId());
     ChangeInserter ins;
     try (BatchUpdate bu = newUpdate(owner.id())) {
-      RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
+      RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
       bu.setNotify(NotifyResolver.Result.none());
       ins =
           changeInserterFactory
@@ -842,14 +841,14 @@
   private ObjectId getDestRef(ChangeNotes notes) throws Exception {
     return serverSideTestRepo
         .getRepository()
-        .exactRef(notes.getChange().getDest().get())
+        .exactRef(notes.getChange().getDest().branch())
         .getObjectId();
   }
 
   private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
-    final ObjectId oldId = getDestRef(notes);
-    final ObjectId newId = ObjectId.fromString(psUtil.current(notes).getRevision().get());
-    final String dest = notes.getChange().getDest().get();
+    ObjectId oldId = getDestRef(notes);
+    ObjectId newId = psUtil.current(notes).commitId();
+    String dest = notes.getChange().getDest().branch();
 
     try (BatchUpdate bu = newUpdate(adminId)) {
       bu.addOp(
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index b9b7ab3..dfb0f75 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
@@ -23,7 +24,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -129,14 +129,14 @@
     testRepo.reset(c1_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    String oldETag = changes.parse(ps1_1.getParentKey()).getETag();
+    String oldETag = changes.parse(ps1_1.changeId()).getETag();
 
     testRepo.reset(c2_1);
     pushHead(testRepo, "refs/for/master", false);
     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.changeId()).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));
@@ -498,7 +498,7 @@
 
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-    PatchSet.Id ps2_edit = new PatchSet.Id(ch2.getId(), 0);
+    PatchSet.Id ps2_edit = PatchSet.id(ch2.getId(), 0);
     PatchSet.Id ps3_1 = getPatchSetId(c3_1);
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
@@ -512,7 +512,7 @@
     assertRelated(
         ps2_edit,
         changeAndCommit(ps3_1, c3_1, 1),
-        changeAndCommit(new PatchSet.Id(ch2.getId(), 0), editRev, 1),
+        changeAndCommit(PatchSet.id(ch2.getId(), 0), editRev, 1),
         changeAndCommit(ps1_1, c1_1, 1));
   }
 
@@ -533,7 +533,7 @@
 
     // Pretend PS1,1 was pushed before the groups field was added.
     clearGroups(psId1_1);
-    indexer.index(changeDataFactory.create(project, psId1_1.getParentKey()));
+    indexer.index(changeDataFactory.create(project, psId1_1.changeId()));
 
     // PS1,1 has no groups, so disappeared from related changes.
     assertRelated(psId2_1);
@@ -568,7 +568,7 @@
 
     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);
+    PatchSet.Id psId2_2 = PatchSet.id(psId2_1.changeId(), psId2_1.get() + 1);
 
     assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
   }
@@ -651,13 +651,8 @@
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<RelatedChangeAndCommitInfo, String>() {
-          @Override
-          public boolean apply(
-              RelatedChangeAndCommitInfo relatedChangeAndCommitInfo, String status) {
-            return Objects.equals(relatedChangeAndCommitInfo.status, status);
-          }
-        },
+        (relatedChangeAndCommitInfo, status) ->
+            Objects.equals(relatedChangeAndCommitInfo.status, status),
         "has status");
   }
 
@@ -678,7 +673,7 @@
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
     RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
     result.project = project.get();
-    result._changeNumber = psId.getParentKey().get();
+    result._changeNumber = psId.changeId().get();
     result.commit = new CommitInfo();
     result.commit.commit = commitId.name();
     result._revisionNumber = psId.get();
@@ -690,12 +685,11 @@
   private void clearGroups(PatchSet.Id psId) throws Exception {
     try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.nowTs())) {
       bu.addOp(
-          psId.getParentKey(),
+          psId.changeId(),
           new BatchUpdateOp() {
             @Override
             public boolean updateChange(ChangeContext ctx) {
-              PatchSet ps = psUtil.get(ctx.getNotes(), psId);
-              psUtil.setGroups(ctx.getUpdate(psId), ps, ImmutableList.of());
+              ctx.getUpdate(psId).setGroups(ImmutableList.of());
               return true;
             }
           });
@@ -711,19 +705,19 @@
   private void assertRelated(PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected)
       throws Exception {
     List<RelatedChangeAndCommitInfo> actual =
-        gApi.changes().id(psId.getParentKey().get()).revision(psId.get()).related().changes;
-    assertThat(actual).named("related to " + psId).hasSize(expected.size());
+        gApi.changes().id(psId.changeId().get()).revision(psId.get()).related().changes;
+    assertWithMessage("related to " + psId).that(actual).hasSize(expected.size());
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
       RelatedChangeAndCommitInfo a = actual.get(i);
       RelatedChangeAndCommitInfo e = expected.get(i);
-      assertThat(a.project).named("project of " + name).isEqualTo(e.project);
-      assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
+      assertWithMessage("project of " + name).that(a.project).isEqualTo(e.project);
+      assertWithMessage("change ID of " + name).that(a._changeNumber).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)
+      assertWithMessage("revision of " + name).that(a._revisionNumber).isEqualTo(e._revisionNumber);
+      assertWithMessage("commit of " + name).that(a.commit.commit).isEqualTo(e.commit.commit);
+      assertWithMessage("current revision of " + name)
+          .that(a._currentRevisionNumber)
           .isEqualTo(e._currentRevisionNumber);
       assertThat(a.status).isEqualTo(e.status);
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/BUILD b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
index bdb3f3b..ee3bcb0 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
@@ -7,9 +7,6 @@
         "notedb",
         "server",
     ],
-    # TODO(dborowitz): Fix leaks in local disk tests so we can reduce heap size.
-    # http://crbug.com/gerrit/8567
-    vm_args = ["-Xmx1024m"],
     deps = [
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index 748c4ea..708d162 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static java.util.stream.Collectors.toList;
@@ -188,7 +189,7 @@
 
   @Test
   public void missingChange() throws Exception {
-    Change.Id changeId = new Change.Id(1234567);
+    Change.Id changeId = Change.id(1234567);
     assertNoSuchChangeException(() -> notesFactory.create(project, changeId));
     assertNoSuchChangeException(() -> notesFactory.createChecked(project, changeId));
   }
@@ -306,8 +307,8 @@
     if (repo instanceof InMemoryRepository) {
       ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
     } else {
-      assertThat(repo.getRefDatabase().performsAtomicTransactions())
-          .named("performsAtomicTransactions on %s", repo)
+      assertWithMessage("performsAtomicTransactions on %s", repo)
+          .that(repo.getRefDatabase().performsAtomicTransactions())
           .isTrue();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
index 2919e5f..bea3633 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendConditionIT.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -122,7 +122,7 @@
 
   @Test
   public void refPermissions_sameResourceAndUserEquals() throws Exception {
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
 
@@ -132,7 +132,7 @@
 
   @Test
   public void refPermissions_sameResourceAndDifferentUserDoesNotEqual() throws Exception {
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    BranchNameKey branch = BranchNameKey.create(project, "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(admin()).ref(branch).testCond(RefPermission.READ);
 
@@ -142,8 +142,8 @@
 
   @Test
   public void refPermissions_differentResourceAndSameUserDoesNotEqual() throws Exception {
-    Branch.NameKey branch1 = new Branch.NameKey(project, "branch");
-    Branch.NameKey branch2 = new Branch.NameKey(project, "branch2");
+    BranchNameKey branch1 = BranchNameKey.create(project, "branch");
+    BranchNameKey branch2 = BranchNameKey.create(project, "branch2");
     BooleanCondition cond1 = pb.user(user()).ref(branch1).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch2).testCond(RefPermission.READ);
 
@@ -153,8 +153,8 @@
 
   @Test
   public void refPermissions_differentResourceAndSameUserDoesNotEqual2() throws Exception {
-    Branch.NameKey branch1 = new Branch.NameKey(project, "branch");
-    Branch.NameKey branch2 = new Branch.NameKey(projectOperations.newProject().create(), "branch");
+    BranchNameKey branch1 = BranchNameKey.create(project, "branch");
+    BranchNameKey branch2 = BranchNameKey.create(projectOperations.newProject().create(), "branch");
     BooleanCondition cond1 = pb.user(user()).ref(branch1).testCond(RefPermission.READ);
     BooleanCondition cond2 = pb.user(user()).ref(branch2).testCond(RefPermission.READ);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6cbe40e..21a7d95 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.project.testing.Util.category;
 import static com.google.gerrit.server.project.testing.Util.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -265,15 +266,17 @@
     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);
+    ReviewInput postSubmitReview1 = new ReviewInput();
+    postSubmitReview1.label(P.getName(), P.getMax().getValue());
+    revision(r).review(postSubmitReview1);
 
-    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);
+    ReviewInput postSubmitReview2 = new ReviewInput();
+    postSubmitReview2.label(label.getName(), label.getMax().getValue());
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision(r).review(postSubmitReview2));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Voting on labels disallowed after submit: " + label.getName());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 4ed16ee..f3b5009 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -218,7 +218,7 @@
     // push a change to watched project -> should trigger email notification
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "TRIGGER", "a", "a1")
@@ -229,7 +229,7 @@
     // notification
     String notWatchedProject = projectOperations.newProject().create().get();
     TestRepository<InMemoryRepository> notWatchedRepo =
-        cloneProject(new Project.NameKey(notWatchedProject), admin);
+        cloneProject(Project.nameKey(notWatchedProject), admin);
     r =
         pushFactory
             .create(admin.newIdent(), notWatchedRepo, "DONT_TRIGGER", "a", "a1")
@@ -261,7 +261,7 @@
     // user
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
@@ -310,7 +310,7 @@
     // push a change with keyword -> should trigger email notification
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "Document multimaster setup", "a.txt", "a1")
@@ -347,8 +347,7 @@
 
     // push a change to any project -> should trigger email notification
     requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory.create(admin.newIdent(), anyRepo, "TRIGGER", "a", "a1").to("refs/for/master");
     r.assertOkStatus();
@@ -374,8 +373,7 @@
     // push a change to watched file in any project -> should trigger email
     // notification for user
     requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
@@ -424,8 +422,7 @@
     // push a change with keyword to any project -> should trigger email
     // notification
     requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
+    TestRepository<InMemoryRepository> anyRepo = cloneProject(Project.nameKey(anyProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), anyRepo, "Document multimaster setup", "a.txt", "a1")
@@ -463,7 +460,7 @@
     // push a change to watched project
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "ignored change", "a", "a1")
@@ -496,7 +493,7 @@
     // push a private change to watched project -> should not trigger email notification
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "private change", "a", "a1")
@@ -515,11 +512,11 @@
     GroupInfo groupThatCanViewPrivateChanges =
         gApi.groups().create("groupThatCanViewPrivateChanges").get();
     grant(
-        new Project.NameKey(watchedProject),
+        Project.nameKey(watchedProject),
         "refs/*",
         Permission.VIEW_PRIVATE_CHANGES,
         false,
-        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+        AccountGroup.uuid(groupThatCanViewPrivateChanges.id));
 
     // watch project as user that can't view private changes
     requestScopeOperations.setApiUser(user.id());
@@ -536,7 +533,7 @@
     // userThatCanViewPrivateChanges, but not for user
     requestScopeOperations.setApiUser(admin.id());
     TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
+        cloneProject(Project.nameKey(watchedProject), admin);
     PushOneCommit.Result r =
         pushFactory
             .create(admin.newIdent(), watchedRepo, "TRIGGER", "a", "a1")
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index da3a257..060a9c3 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -55,7 +57,7 @@
 
       gApi.changes().id(id.get()).topic("foo");
       ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
-      assertThat(last).named("last RefLogEntry").isNotNull();
+      assertWithMessage("last RefLogEntry").that(last).isNotNull();
       assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
     }
   }
@@ -85,8 +87,8 @@
   @Test
   public void regularUserIsNotAllowedToGetReflog() throws Exception {
     requestScopeOperations.setApiUser(user.id());
-    exception.expect(AuthException.class);
-    gApi.projects().name(project.get()).branch("master").reflog();
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(project.get()).branch("master").reflog());
   }
 
   @Test
@@ -95,8 +97,7 @@
     groupApi.addMembers("user");
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      Util.allow(
-          u.getConfig(), Permission.OWNER, new AccountGroup.UUID(groupApi.get().id), "refs/*");
+      Util.allow(u.getConfig(), Permission.OWNER, AccountGroup.uuid(groupApi.get().id), "refs/*");
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
index 286e5ae..47d23a4 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/DefaultQuotaBackendIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.quota;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.expect;
 import static org.easymock.EasyMock.replay;
 import static org.easymock.EasyMock.resetToStrict;
@@ -133,9 +134,8 @@
 
     QuotaResponse.Aggregated result = quotaBackend.user(identifiedAdmin).requestToken("testGroup");
     assertThat(result).isEqualTo(singletonAggregation(QuotaResponse.error("failed")));
-    exception.expect(QuotaException.class);
-    exception.expectMessage("failed");
-    result.throwOnError();
+    QuotaException thrown = assertThrows(QuotaException.class, () -> result.throwOnError());
+    assertThat(thrown).hasMessageThat().contains("failed");
   }
 
   @Test
@@ -143,9 +143,9 @@
     QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build();
     expect(quotaEnforcer.requestTokens("testGroup", ctx, 1)).andThrow(new NullPointerException());
     replay(quotaEnforcer);
-
-    exception.expect(NullPointerException.class);
-    quotaBackend.user(identifiedAdmin).requestToken("testGroup");
+    assertThrows(
+        NullPointerException.class,
+        () -> quotaBackend.user(identifiedAdmin).requestToken("testGroup"));
   }
 
   private static QuotaResponse.Aggregated singletonAggregation(QuotaResponse response) {
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index fa13be4..c6f2024 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -32,6 +32,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class PrologRuleEvaluatorIT extends AbstractDaemonTest {
@@ -149,7 +150,7 @@
   }
 
   private ChangeData makeChangeData() {
-    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
     cd.setChange(TestChanges.newChange(project, admin.id()));
     return cd;
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index 0ce05b0..007ad89 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -33,7 +33,7 @@
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + newGroupName + " " + newProjectName);
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
   }
 
@@ -46,7 +46,7 @@
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + wrongGroupName + " " + newProjectName);
     adminSshSession.assertFailure();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNull();
   }
 
@@ -62,7 +62,7 @@
             + newProjectName
             + ".git");
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertThat(projectState.getName()).isEqualTo(newProjectName);
   }
@@ -79,7 +79,7 @@
             + newProjectName
             + "/");
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertThat(projectState.getName()).isEqualTo(newProjectName);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index 0f47a4a..78960bb 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -294,8 +295,8 @@
     // computation while formatting the output, such as labels, reviewers etc.
     merge(r);
     for (ListChangesOption option : ListChangesOption.values()) {
-      assertThat(gApi.changes().query(r.getChangeId()).withOption(option).get())
-          .named("Option: " + option)
+      assertWithMessage("Option: " + option)
+          .that(gApi.changes().query(r.getChangeId()).withOption(option).get())
           .hasSize(1);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
index 03b7143..fd51618 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
@@ -55,7 +57,7 @@
   @Test
   public void zipFormat() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
+    String abbreviated = abbreviateName(r);
     String c = command(r, "zip", abbreviated);
 
     InputStream out =
@@ -92,7 +94,7 @@
   @Test
   public void txzFormat() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
+    String abbreviated = abbreviateName(r);
     String c = command(r, "tar.xz", abbreviated);
 
     try (InputStream out =
@@ -130,7 +132,7 @@
 
   private void assertArchiveNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
+    String abbreviated = abbreviateName(r);
     String c = command(r, "zip", abbreviated);
 
     InputStream out =
@@ -146,6 +148,10 @@
     assertThat(tmp).isEqualTo("fatal: upload-archive not permitted for format zip");
   }
 
+  private String abbreviateName(Result r) throws Exception {
+    return ObjectIds.abbreviateName(r.getCommit(), 8, testRepo.getRevWalk().getObjectReader());
+  }
+
   private InputStream argumentsToInputStream(String c) throws Exception {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     PacketLineOut pctOut = new PacketLineOut(out);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index e0d6593..bb84689 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.acceptance.testsuite.group;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -217,7 +218,7 @@
 
   @Test
   public void notExistingGroupCanBeCheckedForExistence() throws Exception {
-    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
+    AccountGroup.UUID notExistingGroupUuid = AccountGroup.uuid("not-existing-group");
 
     boolean exists = groupOperations.group(notExistingGroupUuid).exists();
 
@@ -226,10 +227,9 @@
 
   @Test
   public void retrievingNotExistingGroupFails() throws Exception {
-    AccountGroup.UUID notExistingGroupUuid = new AccountGroup.UUID("not-existing-group");
-
-    exception.expect(IllegalStateException.class);
-    groupOperations.group(notExistingGroupUuid).get();
+    AccountGroup.UUID notExistingGroupUuid = AccountGroup.uuid("not-existing-group");
+    assertThrows(
+        IllegalStateException.class, () -> groupOperations.group(notExistingGroupUuid).get());
   }
 
   @Test
@@ -270,7 +270,7 @@
 
     AccountGroup.NameKey groupName = groupOperations.group(groupUuid).get().nameKey();
 
-    assertThat(groupName).isEqualTo(new AccountGroup.NameKey("ABC-789-this-name-must-be-unique"));
+    assertThat(groupName).isEqualTo(AccountGroup.nameKey("ABC-789-this-name-must-be-unique"));
   }
 
   @Test
@@ -297,7 +297,7 @@
 
   @Test
   public void ownerGroupUuidOfExistingGroupCanBeRetrieved() throws Exception {
-    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("owner group");
+    AccountGroup.UUID originalOwnerGroupUuid = AccountGroup.uuid("owner group");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
 
@@ -314,14 +314,16 @@
     TestGroup visibleGroup = groupOperations.group(visibleGroupUuid).get();
     TestGroup invisibleGroup = groupOperations.group(invisibleGroupUuid).get();
 
-    assertThat(visibleGroup.visibleToAll()).named("visibility of visible group").isTrue();
-    assertThat(invisibleGroup.visibleToAll()).named("visibility of invisible group").isFalse();
+    assertWithMessage("visibility of visible group").that(visibleGroup.visibleToAll()).isTrue();
+    assertWithMessage("visibility of invisible group")
+        .that(invisibleGroup.visibleToAll())
+        .isFalse();
   }
 
   @Test
   public void createdOnOfExistingGroupCanBeRetrieved() throws Exception {
     GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(group.id);
 
     Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
 
@@ -330,9 +332,9 @@
 
   @Test
   public void membersOfExistingGroupCanBeRetrieved() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
-    Account.Id memberId3 = new Account.Id(3000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
+    Account.Id memberId3 = Account.id(3000);
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().members(memberId1, memberId2, memberId3).create();
 
@@ -352,9 +354,9 @@
 
   @Test
   public void subgroupsOfExistingGroupCanBeRetrieved() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
-    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
+    AccountGroup.UUID subgroupUuid3 = AccountGroup.uuid("subgroup 3");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2, subgroupUuid3).create();
 
@@ -428,11 +430,11 @@
 
   @Test
   public void ownerGroupUuidCanBeUpdated() throws Exception {
-    AccountGroup.UUID originalOwnerGroupUuid = new AccountGroup.UUID("original owner");
+    AccountGroup.UUID originalOwnerGroupUuid = AccountGroup.uuid("original owner");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().ownerGroupUuid(originalOwnerGroupUuid).create();
 
-    AccountGroup.UUID updatedOwnerGroupUuid = new AccountGroup.UUID("updated owner");
+    AccountGroup.UUID updatedOwnerGroupUuid = AccountGroup.uuid("updated owner");
     groupOperations.group(groupUuid).forUpdate().ownerGroupUuid(updatedOwnerGroupUuid).update();
 
     AccountGroup.UUID currentOwnerGroupUuid =
@@ -454,8 +456,8 @@
   public void membersCanBeAdded() throws Exception {
     AccountGroup.UUID groupUuid = groupOperations.newGroup().clearMembers().create();
 
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     groupOperations.group(groupUuid).forUpdate().addMember(memberId1).addMember(memberId2).update();
 
     ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
@@ -464,8 +466,8 @@
 
   @Test
   public void membersCanBeRemoved() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
     groupOperations.group(groupUuid).forUpdate().removeMember(memberId2).update();
@@ -476,11 +478,11 @@
 
   @Test
   public void memberAdditionAndRemovalCanBeMixed() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
-    Account.Id memberId3 = new Account.Id(3000);
+    Account.Id memberId3 = Account.id(3000);
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -494,8 +496,8 @@
 
   @Test
   public void membersCanBeCleared() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
     groupOperations.group(groupUuid).forUpdate().clearMembers().update();
@@ -506,11 +508,11 @@
 
   @Test
   public void furtherMembersCanBeAddedAfterClearingAll() throws Exception {
-    Account.Id memberId1 = new Account.Id(1000);
-    Account.Id memberId2 = new Account.Id(2000);
+    Account.Id memberId1 = Account.id(1000);
+    Account.Id memberId2 = Account.id(2000);
     AccountGroup.UUID groupUuid = groupOperations.newGroup().members(memberId1, memberId2).create();
 
-    Account.Id memberId3 = new Account.Id(3000);
+    Account.Id memberId3 = Account.id(3000);
     groupOperations.group(groupUuid).forUpdate().clearMembers().addMember(memberId3).update();
 
     ImmutableSet<Account.Id> members = groupOperations.group(groupUuid).get().members();
@@ -521,8 +523,8 @@
   public void subgroupsCanBeAdded() throws Exception {
     AccountGroup.UUID groupUuid = groupOperations.newGroup().clearSubgroups().create();
 
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -536,8 +538,8 @@
 
   @Test
   public void subgroupsCanBeRemoved() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
@@ -549,12 +551,12 @@
 
   @Test
   public void subgroupAdditionAndRemovalCanBeMixed() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
-    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID subgroupUuid3 = AccountGroup.uuid("subgroup 3");
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -568,8 +570,8 @@
 
   @Test
   public void subgroupsCanBeCleared() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
@@ -581,12 +583,12 @@
 
   @Test
   public void furtherSubgroupsCanBeAddedAfterClearingAll() throws Exception {
-    AccountGroup.UUID subgroupUuid1 = new AccountGroup.UUID("subgroup 1");
-    AccountGroup.UUID subgroupUuid2 = new AccountGroup.UUID("subgroup 2");
+    AccountGroup.UUID subgroupUuid1 = AccountGroup.uuid("subgroup 1");
+    AccountGroup.UUID subgroupUuid2 = AccountGroup.uuid("subgroup 2");
     AccountGroup.UUID groupUuid =
         groupOperations.newGroup().subgroups(subgroupUuid1, subgroupUuid2).create();
 
-    AccountGroup.UUID subgroupUuid3 = new AccountGroup.UUID("subgroup 3");
+    AccountGroup.UUID subgroupUuid3 = AccountGroup.uuid("subgroup 3");
     groupOperations
         .group(groupUuid)
         .forUpdate()
@@ -610,37 +612,31 @@
 
   private AccountGroup.UUID createGroupInServer(GroupInput input) throws RestApiException {
     GroupInfo group = gApi.groups().create(input).detail();
-    return new AccountGroup.UUID(group.id);
+    return AccountGroup.uuid(group.id);
   }
 
   private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<AccountInfo, Account.Id>() {
-          @Override
-          public boolean apply(AccountInfo actualAccount, Account.Id expectedId) {
-            Account.Id accountId =
-                Optional.ofNullable(actualAccount)
-                    .map(account -> account._accountId)
-                    .map(Account.Id::new)
-                    .orElse(null);
-            return Objects.equals(accountId, expectedId);
-          }
+        (actualAccount, expectedId) -> {
+          Account.Id accountId =
+              Optional.ofNullable(actualAccount)
+                  .map(account -> account._accountId)
+                  .map(Account::id)
+                  .orElse(null);
+          return Objects.equals(accountId, expectedId);
         },
         "has ID");
   }
 
   private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
     return Correspondence.from(
-        new BinaryPredicate<GroupInfo, AccountGroup.UUID>() {
-          @Override
-          public boolean apply(GroupInfo actualGroup, AccountGroup.UUID expectedUuid) {
-            AccountGroup.UUID groupUuid =
-                Optional.ofNullable(actualGroup)
-                    .map(group -> group.id)
-                    .map(AccountGroup.UUID::new)
-                    .orElse(null);
-            return Objects.equals(groupUuid, expectedUuid);
-          }
+        (actualGroup, expectedUuid) -> {
+          AccountGroup.UUID groupUuid =
+              Optional.ofNullable(actualGroup)
+                  .map(group -> group.id)
+                  .map(AccountGroup::uuid)
+                  .orElse(null);
+          return Objects.equals(groupUuid, expectedUuid);
         },
         "has UUID");
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 3f537c0..8ecd21c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -15,14 +15,31 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+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.truth.ConfigSubject.assertThat;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
 import org.junit.Test;
 
 public class ProjectOperationsImplTest extends AbstractDaemonTest {
@@ -48,9 +65,322 @@
   @Test
   public void emptyCommit() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
+
     List<BranchInfo> branches = gApi.projects().name(key.get()).branches().get();
     assertThat(branches).isNotEmpty();
     assertThat(branches.stream().map(x -> x.ref).collect(toList()))
         .isEqualTo(ImmutableList.of("HEAD", "refs/meta/config", "refs/heads/master"));
   }
+
+  @Test
+  public void getProjectConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
+        .isEmpty();
+
+    ConfigInput input = new ConfigInput();
+    input.description = "my fancy project";
+    gApi.projects().name(key.get()).config(input);
+
+    assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
+        .isEqualTo("my fancy project");
+  }
+
+  @Test
+  public void mutatingResultOfGetProjectConfigDoesNotMutateGlobalCachedValue() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
+    ProjectState cachedProjectState1 = projectCache.checkedGet(key);
+    ProjectConfig cachedProjectConfig1 = cachedProjectState1.getConfig();
+    assertThat(cachedProjectConfig1).isNotSameInstanceAs(projectConfig);
+    assertThat(cachedProjectConfig1.getProject().getDescription()).isEmpty();
+    assertThat(projectConfig.getProject().getDescription()).isEmpty();
+    projectConfig.getProject().setDescription("my fancy project");
+
+    ProjectConfig cachedProjectConfig2 = projectCache.checkedGet(key).getConfig();
+    assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
+    assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
+  }
+
+  @Test
+  public void getProjectConfigNoRefsMetaConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    deleteRefsMetaConfig(key);
+
+    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
+    assertThat(projectConfig.getName()).isEqualTo(key);
+    assertThat(projectConfig.getRevision()).isNull();
+  }
+
+  @Test
+  public void getConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).text().isEmpty();
+
+    ConfigInput input = new ConfigInput();
+    input.description = "my fancy project";
+    gApi.projects().name(key.get()).config(input);
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).sections().containsExactly("project");
+    assertThat(config).subsections("project").isEmpty();
+    assertThat(config).sectionValues("project").containsExactly("description", "my fancy project");
+  }
+
+  @Test
+  public void getConfigNoRefsMetaConfig() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    deleteRefsMetaConfig(key);
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).isNotInstanceOf(StoredConfig.class);
+    assertThat(config).isEmpty();
+  }
+
+  @Test
+  public void addAllowPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addDenyPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(TestProjectUpdate.deny(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "deny group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(TestProjectUpdate.block(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "block group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowForcePermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allow(Permission.ABANDON)
+                .ref("refs/foo")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("abandon", "+force group global:Registered-Users");
+  }
+
+  @Test
+  public void addMultiplePermissions() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(PROJECT_OWNERS))
+        .add(TestProjectUpdate.allow(Permission.CREATE).ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Project-Owners",
+            "create", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addDuplicatePermissions() throws Exception {
+    TestPermission permission =
+        TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).build();
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations.project(key).forUpdate().add(permission).add(permission).update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users");
+
+    projectOperations.project(key).forUpdate().add(permission).update();
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users",
+            "abandon", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review")
+                .ref("refs/foo")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.blockLabel("Code-Review")
+                .ref("refs/foo")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("label-Code-Review", "block -1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowExclusiveLabelPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review")
+                .ref("refs/foo")
+                .group(REGISTERED_USERS)
+                .range(-1, 2)
+                .exclusive(true))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "label-Code-Review", "-1..+2 group global:Registered-Users",
+            "exclusiveGroupPermissions", "label-Code-Review");
+  }
+
+  @Test
+  public void addAllowCapability() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowCapability(ADMINISTRATE_SERVER).group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("capability");
+    assertThat(config).subsections("capability").isEmpty();
+    assertThat(config)
+        .sectionValues("capability")
+        .containsExactly("administrateServer", "group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowCapabilityWithRange() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 5000))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("capability");
+    assertThat(config).subsections("capability").isEmpty();
+    assertThat(config)
+        .sectionValues("capability")
+        .containsExactly("queryLimit", "+0..+5000 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowCapabilityWithDefaultRange() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("capability");
+    assertThat(config).subsections("capability").isEmpty();
+    assertThat(config)
+        .sectionValues("capability")
+        .containsExactly(
+            "queryLimit", "+0..+" + DEFAULT_MAX_QUERY_LIMIT + " group global:Registered-Users");
+  }
+
+  private void deleteRefsMetaConfig(Project.NameKey key) throws Exception {
+    try (Repository repo = repoManager.openRepository(key)) {
+      new TestRepository<>(repo).delete(REFS_CONFIG);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index 5cbed1b..4d0bb52 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.request;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth8.assertThat;
 
@@ -68,7 +69,7 @@
   public void setApiUserToNonExistingUser() throws Exception {
     fastCheckCurrentUser(admin.id());
     try {
-      requestScopeOperations.setApiUser(new Account.Id(sequences.nextAccountId()));
+      requestScopeOperations.setApiUser(Account.id(sequences.nextAccountId()));
       assert_().fail("expected RuntimeException");
     } catch (RuntimeException e) {
       // Expected.
@@ -98,24 +99,26 @@
 
   private void fastCheckCurrentUser(Account.Id expected) {
     // Check current user quickly, since the full check requires creating changes and is quite slow.
-    assertThat(userProvider.get().isIdentifiedUser())
-        .named("user from provider is an IdentifiedUser")
+    assertWithMessage("user from provider is an IdentifiedUser")
+        .that(userProvider.get().isIdentifiedUser())
         .isTrue();
-    assertThat(userProvider.get().getAccountId()).named("user from provider").isEqualTo(expected);
+    assertWithMessage("user from provider")
+        .that(userProvider.get().getAccountId())
+        .isEqualTo(expected);
   }
 
   private void checkCurrentUser(Account.Id expected) throws Exception {
     // Test all supported ways that an acceptance test might query the active user.
     fastCheckCurrentUser(expected);
-    assertThat(gApi.accounts().self().get()._accountId)
-        .named("user from GerritApi")
+    assertWithMessage("user from GerritApi")
+        .that(gApi.accounts().self().get()._accountId)
         .isEqualTo(expected.get());
     AcceptanceTestRequestScope.Context ctx = atrScope.get();
-    assertThat(ctx.getUser().isIdentifiedUser())
-        .named("user from AcceptanceTestRequestScope.Context is an IdentifiedUser")
+    assertWithMessage("user from AcceptanceTestRequestScope.Context is an IdentifiedUser")
+        .that(ctx.getUser().isIdentifiedUser())
         .isTrue();
-    assertThat(ctx.getUser().getAccountId())
-        .named("user from AcceptanceTestRequestScope.Context")
+    assertWithMessage("user from AcceptanceTestRequestScope.Context")
+        .that(ctx.getUser().getAccountId())
         .isEqualTo(expected);
     checkSshUser(expected);
   }
@@ -131,8 +134,8 @@
     assertThat(gApi.changes().id(changeId).get().owner._accountId).isEqualTo(expected.get());
     String queryResults =
         atrScope.get().getSession().exec("gerrit query owner:self change:" + changeId);
-    assertThat(findDistinct(queryResults, "I[0-9a-f]{40}"))
-        .named("Change-Ids in query results:\n%s", queryResults)
+    assertWithMessage("Change-Ids in query results:\n%s", queryResults)
+        .that(findDistinct(queryResults, "I[0-9a-f]{40}"))
         .containsExactly(changeId);
   }
 
diff --git a/javatests/com/google/gerrit/common/AutoValueTest.java b/javatests/com/google/gerrit/common/AutoValueTest.java
index 89d7bf4..947fe4a 100644
--- a/javatests/com/google/gerrit/common/AutoValueTest.java
+++ b/javatests/com/google/gerrit/common/AutoValueTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class AutoValueTest extends GerritBaseTests {
+public class AutoValueTest {
   @AutoValue
   abstract static class Auto {
     static Auto create(String val) {
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
index faf9d6c..e775cbc 100644
--- a/javatests/com/google/gerrit/common/data/AccessSectionTest.java
+++ b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.common.data;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import org.junit.Before;
 import org.junit.Test;
 
-public class AccessSectionTest extends GerritBaseTests {
+public class AccessSectionTest {
   private static final String REF_PATTERN = "refs/heads/master";
 
   private AccessSection accessSection;
@@ -57,16 +57,17 @@
     Permission submitPermission = new Permission(Permission.SUBMIT);
     accessSection.setPermissions(ImmutableList.of(submitPermission));
     assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
-
-    exception.expect(NullPointerException.class);
-    accessSection.setPermissions(null);
+    assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
   }
 
   @Test
   public void cannotSetDuplicatePermissions() {
-    exception.expect(IllegalArgumentException.class);
-    accessSection.setPermissions(
-        ImmutableList.of(new Permission(Permission.ABANDON), new Permission(Permission.ABANDON)));
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection.setPermissions(
+                ImmutableList.of(
+                    new Permission(Permission.ABANDON), new Permission(Permission.ABANDON))));
   }
 
   @Test
@@ -76,9 +77,11 @@
     Permission abandonPermissionUpperCase =
         new Permission(Permission.ABANDON.toUpperCase(Locale.US));
 
-    exception.expect(IllegalArgumentException.class);
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase));
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection.setPermissions(
+                ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase)));
   }
 
   @Test
@@ -92,9 +95,7 @@
     Permission submitPermission = new Permission(Permission.SUBMIT);
     accessSection.setPermissions(ImmutableList.of(submitPermission));
     assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-
-    exception.expect(NullPointerException.class);
-    accessSection.getPermission(null);
+    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null));
   }
 
   @Test
@@ -112,8 +113,7 @@
     assertThat(accessSection.getPermission(Permission.SUBMIT, true))
         .isEqualTo(new Permission(Permission.SUBMIT));
 
-    exception.expect(NullPointerException.class);
-    accessSection.getPermission(null, true);
+    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null, true));
   }
 
   @Test
@@ -130,9 +130,7 @@
     assertThat(accessSection.getPermissions())
         .containsExactly(abandonPermission, rebasePermission, submitPermission)
         .inOrder();
-
-    exception.expect(NullPointerException.class);
-    accessSection.addPermission(null);
+    assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
   }
 
   @Test
@@ -166,9 +164,7 @@
     assertThat(accessSection.getPermissions())
         .containsExactly(abandonPermission, rebasePermission)
         .inOrder();
-
-    exception.expect(NullPointerException.class);
-    accessSection.remove(null);
+    assertThrows(NullPointerException.class, () -> accessSection.remove(null));
   }
 
   @Test
@@ -187,8 +183,7 @@
         .containsExactly(abandonPermission, rebasePermission)
         .inOrder();
 
-    exception.expect(NullPointerException.class);
-    accessSection.removePermission(null);
+    assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
   }
 
   @Test
@@ -229,9 +224,7 @@
     assertThat(accessSection1.getPermissions())
         .containsExactly(abandonPermission, rebasePermission, submitPermission)
         .inOrder();
-
-    exception.expect(NullPointerException.class);
-    accessSection.mergeFrom(null);
+    assertThrows(NullPointerException.class, () -> accessSection.mergeFrom(null));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
index 3dd2db3..dcd3c05 100644
--- a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class EncodePathSeparatorTest extends GerritBaseTests {
+public class EncodePathSeparatorTest {
   @Test
   public void defaultBehaviour() {
     assertThat(new GitwebType().replacePathSeparator("a/b")).isEqualTo("a/b");
diff --git a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
index 055f57d..ec71e05 100644
--- a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class FilenameComparatorTest extends GerritBaseTests {
+public class FilenameComparatorTest {
   private FilenameComparator comparator = FilenameComparator.INSTANCE;
 
   @Test
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 8cf486b..c4f59a1 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.common.data;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class GroupReferenceTest extends GerritBaseTests {
+public class GroupReferenceTest {
   @Test
   public void forGroupDescription() {
     String name = "foo";
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
     GroupReference groupReference =
         GroupReference.forGroup(
             new GroupDescription.Basic() {
@@ -56,7 +56,7 @@
 
   @Test
   public void create() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid");
     String name = "foo";
     GroupReference groupReference = new GroupReference(uuid, name);
     assertThat(groupReference.getUUID()).isEqualTo(uuid);
@@ -68,15 +68,15 @@
     // GroupReferences where the UUID is null are used to represent groups from project.config that
     // cannot be resolved.
     String name = "foo";
-    GroupReference groupReference = new GroupReference(null, name);
+    GroupReference groupReference = new GroupReference(name);
     assertThat(groupReference.getUUID()).isNull();
     assertThat(groupReference.getName()).isEqualTo(name);
   }
 
   @Test
   public void cannotCreateWithoutName() {
-    exception.expect(NullPointerException.class);
-    new GroupReference(new AccountGroup.UUID("uuid"), null);
+    assertThrows(
+        NullPointerException.class, () -> new GroupReference(AccountGroup.uuid("uuid"), null));
   }
 
   @Test
@@ -99,12 +99,12 @@
 
   @Test
   public void getAndSetUuid() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
     String name = "foo";
     GroupReference groupReference = new GroupReference(uuid, name);
     assertThat(groupReference.getUUID()).isEqualTo(uuid);
 
-    AccountGroup.UUID uuid2 = new AccountGroup.UUID("uuid-bar");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
     groupReference.setUUID(uuid2);
     assertThat(groupReference.getUUID()).isEqualTo(uuid2);
 
@@ -116,7 +116,7 @@
 
   @Test
   public void getAndSetName() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("uuid-foo");
+    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
     String name = "foo";
     GroupReference groupReference = new GroupReference(uuid, name);
     assertThat(groupReference.getName()).isEqualTo(name);
@@ -125,21 +125,20 @@
     groupReference.setName(name2);
     assertThat(groupReference.getName()).isEqualTo(name2);
 
-    exception.expect(NullPointerException.class);
-    groupReference.setName(null);
+    assertThrows(NullPointerException.class, () -> groupReference.setName(null));
   }
 
   @Test
   public void toConfigValue() {
     String name = "foo";
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-foo"), name);
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-foo"), name);
     assertThat(groupReference.toConfigValue()).isEqualTo("group " + name);
   }
 
   @Test
   public void testEquals() {
-    AccountGroup.UUID uuid1 = new AccountGroup.UUID("uuid-foo");
-    AccountGroup.UUID uuid2 = new AccountGroup.UUID("uuid-bar");
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid-foo");
+    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
     String name1 = "foo";
     String name2 = "bar";
 
@@ -154,12 +153,11 @@
 
   @Test
   public void testHashcode() {
-    AccountGroup.UUID uuid1 = new AccountGroup.UUID("uuid1");
+    AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
     assertThat(new GroupReference(uuid1, "foo").hashCode())
         .isEqualTo(new GroupReference(uuid1, "bar").hashCode());
 
     // Check that the following calls don't fail with an exception.
-    new GroupReference(null, "bar").hashCode();
-    new GroupReference(new AccountGroup.UUID(null), "bar").hashCode();
+    new GroupReference("bar").hashCode();
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
index a534a9e..8f2778a 100644
--- a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
@@ -22,18 +22,17 @@
 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.testing.GerritBaseTests;
-import java.sql.Date;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
-public class LabelFunctionTest extends GerritBaseTests {
+public class LabelFunctionTest {
   private static final String LABEL_NAME = "Verified";
-  private static final LabelId LABEL_ID = new LabelId(LABEL_NAME);
-  private static final Change.Id CHANGE_ID = new Change.Id(100);
-  private static final PatchSet.Id PS_ID = new PatchSet.Id(CHANGE_ID, 1);
+  private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
+  private static final Change.Id CHANGE_ID = Change.id(100);
+  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
   private static final LabelType VERIFIED_LABEL = makeLabel();
   private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
   private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
@@ -82,7 +81,7 @@
     SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
   }
 
   private static LabelType makeLabel() {
@@ -97,14 +96,11 @@
   }
 
   private static PatchSetApproval makeApproval(int value) {
-    Account.Id accountId = new Account.Id(10000 + value);
-    PatchSetApproval.Key key = makeKey(PS_ID, accountId, LABEL_ID);
-    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
-  }
-
-  private static PatchSetApproval.Key makeKey(
-      PatchSet.Id psId, Account.Id accountId, LabelId labelId) {
-    return new PatchSetApproval.Key(psId, accountId, labelId);
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
   }
 
   private static void checkBlockWorks(LabelFunction function) {
@@ -113,7 +109,7 @@
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
   }
 
   private static void checkNothingHappens(LabelFunction function) {
@@ -144,6 +140,6 @@
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
     assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.getAccountId());
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
index db0df2e..6c3befb 100644
--- a/javatests/com/google/gerrit/common/data/LabelTypeTest.java
+++ b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class LabelTypeTest extends GerritBaseTests {
+public class LabelTypeTest {
   @Test
   public void sortLabelValues() {
     LabelValue v0 = new LabelValue((short) 0, "Zero");
diff --git a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
index b22a511..b646d2b 100644
--- a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
+++ b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.HashMap;
 import java.util.Map;
 import org.junit.Test;
 
-public class ParameterizedStringTest extends GerritBaseTests {
+public class ParameterizedStringTest {
   @Test
   public void emptyString() {
     ParameterizedString p = new ParameterizedString("");
diff --git a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
index f442f39..1b70a8a 100644
--- a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
+++ b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
@@ -15,20 +15,20 @@
 package com.google.gerrit.common.data;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Before;
 import org.junit.Test;
 
-public class PermissionRuleTest extends GerritBaseTests {
+public class PermissionRuleTest {
   private GroupReference groupReference;
   private PermissionRule permissionRule;
 
   @Before
   public void setup() {
-    this.groupReference = new GroupReference(new AccountGroup.UUID("uuid"), "group");
+    this.groupReference = new GroupReference(AccountGroup.uuid("uuid"), "group");
     this.permissionRule = new PermissionRule(groupReference);
   }
 
@@ -42,8 +42,7 @@
 
   @Test
   public void cannotSetActionToNull() {
-    exception.expect(NullPointerException.class);
-    permissionRule.setAction(null);
+    assertThrows(NullPointerException.class, () -> permissionRule.setAction(null));
   }
 
   @Test
@@ -131,7 +130,7 @@
 
   @Test
   public void setGroup() {
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     assertThat(groupReference2).isNotEqualTo(groupReference);
 
     assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
@@ -142,10 +141,10 @@
 
   @Test
   public void mergeFromAnyBlock() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -170,10 +169,10 @@
 
   @Test
   public void mergeFromAnyDeny() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -193,10 +192,10 @@
 
   @Test
   public void mergeFromAnyBatch() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -216,10 +215,10 @@
 
   @Test
   public void mergeFromAnyForce() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -239,11 +238,11 @@
 
   @Test
   public void mergeFromMergeRange() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
     permissionRule1.setRange(-1, 2);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
     permissionRule2.setRange(-2, 1);
 
@@ -256,10 +255,10 @@
 
   @Test
   public void mergeFromGroupNotChanged() {
-    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid1"), "group1");
+    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
     PermissionRule permissionRule1 = new PermissionRule(groupReference1);
 
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRule2 = new PermissionRule(groupReference2);
 
     permissionRule1.mergeFrom(permissionRule2);
@@ -348,7 +347,7 @@
 
   @Test
   public void testEquals() {
-    GroupReference groupReference2 = new GroupReference(new AccountGroup.UUID("uuid2"), "group2");
+    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
     PermissionRule permissionRuleOther = new PermissionRule(groupReference2);
     assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
 
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
index 23380e7..0202b10 100644
--- a/javatests/com/google/gerrit/common/data/PermissionTest.java
+++ b/javatests/com/google/gerrit/common/data/PermissionTest.java
@@ -18,13 +18,12 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 
-public class PermissionTest extends GerritBaseTests {
+public class PermissionTest {
   private static final String PERMISSION_NAME = "foo";
 
   private Permission permission;
@@ -155,14 +154,14 @@
   @Test
   public void setAndGetRules() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
     assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
 
     PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
     permission.setRules(ImmutableList.of(permissionRule3));
     assertThat(permission.getRules()).containsExactly(permissionRule3);
   }
@@ -170,10 +169,10 @@
   @Test
   public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
 
     List<PermissionRule> rules = new ArrayList<>();
     rules.add(permissionRule1);
@@ -188,14 +187,14 @@
 
   @Test
   public void getNonExistingRule() {
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
     assertThat(permission.getRule(groupReference)).isNull();
     assertThat(permission.getRule(groupReference, false)).isNull();
   }
 
   @Test
   public void getRule() {
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
     PermissionRule permissionRule = new PermissionRule(groupReference);
     permission.setRules(ImmutableList.of(permissionRule));
     assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
@@ -203,7 +202,7 @@
 
   @Test
   public void createMissingRuleOnGet() {
-    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
     assertThat(permission.getRule(groupReference)).isNull();
 
     assertThat(permission.getRule(groupReference, true))
@@ -213,11 +212,11 @@
   @Test
   public void addRule() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
     assertThat(permission.getRule(groupReference3)).isNull();
 
     PermissionRule permissionRule3 = new PermissionRule(groupReference3);
@@ -231,10 +230,10 @@
   @Test
   public void removeRule() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
     PermissionRule permissionRule3 = new PermissionRule(groupReference3);
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
@@ -248,10 +247,10 @@
   @Test
   public void removeRuleByGroupReference() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
     PermissionRule permissionRule3 = new PermissionRule(groupReference3);
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
@@ -265,9 +264,9 @@
   @Test
   public void clearRules() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
     assertThat(permission.getRules()).isNotEmpty();
@@ -279,11 +278,11 @@
   @Test
   public void mergePermissions() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
     PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
 
     Permission permission1 = new Permission("foo");
     permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
@@ -300,9 +299,9 @@
   @Test
   public void testEquals() {
     PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
     PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
 
     permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
 
diff --git a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
index 5b9fde7..5386b87 100644
--- a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
+++ b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.Collection;
 import org.junit.Test;
 
-public class SubmitRecordTest extends GerritBaseTests {
+public class SubmitRecordTest {
   private static final SubmitRecord OK_RECORD;
   private static final SubmitRecord FORCED_RECORD;
   private static final SubmitRecord NOT_READY_RECORD;
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
index 9ce1456..7e044c3 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
@@ -21,17 +21,17 @@
 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.SECTION_ELASTICSEARCH;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.ProvisionException;
 import java.util.Arrays;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class ElasticConfigurationTest extends GerritBaseTests {
+public class ElasticConfigurationTest {
   @Test
   public void singleServerNoOtherConfig() throws Exception {
     Config cfg = newConfig();
@@ -121,9 +121,9 @@
         .containsExactly(hostURIs);
   }
 
-  private void assertProvisionException(Config cfg) throws Exception {
-    exception.expect(ProvisionException.class);
-    exception.expectMessage("No valid Elasticsearch servers configured");
-    new ElasticConfiguration(cfg);
+  private void assertProvisionException(Config cfg) {
+    ProvisionException thrown =
+        assertThrows(ProvisionException.class, () -> new ElasticConfiguration(cfg));
+    assertThat(thrown).hasMessageThat().contains("No valid Elasticsearch servers configured");
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
index 27868d2..e5bd19f 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
index 2e4e22a..e1aadb8 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
@@ -24,6 +25,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Rule;
 
 public class ElasticV5QueryChangesTest extends AbstractQueryChangesTest {
   @ConfigSuite.Default
@@ -52,6 +54,8 @@
     }
   }
 
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -62,7 +66,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
index 98c4321..fcec859 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
index 6b4b58c..16f06d5 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index 219eecd..9c79270 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 1b0822c..8a20e07 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
@@ -24,6 +25,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Rule;
 
 public class ElasticV6QueryChangesTest extends AbstractQueryChangesTest {
   @ConfigSuite.Default
@@ -52,6 +54,8 @@
     }
   }
 
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -62,7 +66,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index 2782b7f..4f152bd 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
index f01138a..96d9296 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index 6972a18..a329c8a 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index 988abca..a2d3c6d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
 import com.google.inject.Guice;
@@ -29,6 +30,7 @@
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Rule;
 
 public class ElasticV7QueryChangesTest extends AbstractQueryChangesTest {
   @ConfigSuite.Default
@@ -60,12 +62,15 @@
     }
   }
 
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @After
   public void closeIndex() {
     client.execute(
         new HttpPost(
             String.format(
-                "http://localhost:%d/%s*/_close", nodeInfo.port, getSanitizedMethodName())),
+                "http://localhost:%d/%s*/_close",
+                nodeInfo.port, testName.getSanitizedMethodName())),
         HttpClientContext.create(),
         null);
   }
@@ -80,7 +85,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 534bc36..e487c56 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 1f4653c..20cc90f 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -62,7 +62,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = getSanitizedMethodName();
+    String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index 61ad068..0fe073c 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.elasticsearch;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class ElasticVersionTest extends GerritBaseTests {
+public class ElasticVersionTest {
   @Test
   public void supportedVersion() throws Exception {
     assertThat(ElasticVersion.forVersion("5.6.0")).isEqualTo(ElasticVersion.V5_6);
@@ -49,10 +49,14 @@
 
   @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");
+    ElasticVersion.UnsupportedVersion thrown =
+        assertThrows(
+            ElasticVersion.UnsupportedVersion.class, () -> ElasticVersion.forVersion("4.0.0"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Unsupported version: [4.0.0]. Supported versions: "
+                + ElasticVersion.supportedVersions());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java b/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
index 86dce04..0be10ee 100644
--- a/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
+++ b/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.junit.Test;
 
-public class LfsDefinitionsTest extends GerritBaseTests {
+public class LfsDefinitionsTest {
   private static final String[] URL_PREFIXES = new String[] {"/", "/a/", "/p/", "/a/p/"};
 
   @Test
diff --git a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
index 4bb9107..5e8c7b6 100644
--- a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
+++ b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
@@ -21,11 +21,10 @@
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.FOO;
 
 import com.google.common.math.IntMath;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.EnumSet;
 import org.junit.Test;
 
-public class ListOptionTest extends GerritBaseTests {
+public class ListOptionTest {
   enum MyOption implements ListOption {
     FOO(0),
     BAR(1),
diff --git a/javatests/com/google/gerrit/extensions/client/RangeTest.java b/javatests/com/google/gerrit/extensions/client/RangeTest.java
index 2c713b5..b8938aa 100644
--- a/javatests/com/google/gerrit/extensions/client/RangeTest.java
+++ b/javatests/com/google/gerrit/extensions/client/RangeTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.gerrit.extensions.common.testing.RangeSubject.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class RangeTest extends GerritBaseTests {
+public class RangeTest {
 
   @Test
   public void rangeOverMultipleLinesWithSmallerEndCharacterIsValid() {
diff --git a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
index 81cb719..f9f1fa85 100644
--- a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
+++ b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
@@ -20,10 +20,9 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.valueOf;
 import static org.junit.Assert.assertEquals;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class BooleanConditionTest extends GerritBaseTests {
+public class BooleanConditionTest {
 
   private static final BooleanCondition NO_TRIVIAL_EVALUATION =
       new BooleanCondition() {
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
index d950224..0542c35 100644
--- a/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
@@ -17,14 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.toSet;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 import java.util.Iterator;
 import org.junit.Test;
 
-public class DynamicSetTest extends GerritBaseTests {
+public class DynamicSetTest {
   // In tests for {@link DynamicSet#contains(Object)}, be sure to avoid
   // {@code assertThat(ds).contains(...) @} and
   // {@code assertThat(ds).DoesNotContains(...) @} as (since
diff --git a/javatests/com/google/gerrit/git/BUILD b/javatests/com/google/gerrit/git/BUILD
index d57d73f..ca272b2 100644
--- a/javatests/com/google/gerrit/git/BUILD
+++ b/javatests/com/google/gerrit/git/BUILD
@@ -31,6 +31,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/git/ObjectIdsTest.java b/javatests/com/google/gerrit/git/ObjectIdsTest.java
new file mode 100644
index 0000000..36c10a4
--- /dev/null
+++ b/javatests/com/google/gerrit/git/ObjectIdsTest.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
+
+import java.util.function.Function;
+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.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevBlob;
+import org.junit.Test;
+
+public class ObjectIdsTest {
+  private static final ObjectId ID =
+      ObjectId.fromString("0000000000100000000000000000000000000000");
+  private static final ObjectId AMBIGUOUS_BLOB_ID =
+      ObjectId.fromString("0000000000b36b6aa7ea4b75318ed078f55505c3");
+  private static final ObjectId AMBIGUOUS_TREE_ID =
+      ObjectId.fromString("0000000000cdcf04beb2fab69e65622616294984");
+
+  @Test
+  public void abbreviateNameDefaultLength() throws Exception {
+    assertRuntimeException(() -> abbreviateName(null));
+    assertThat(abbreviateName(ID)).isEqualTo("0000000");
+    assertThat(abbreviateName(AMBIGUOUS_BLOB_ID)).isEqualTo(abbreviateName(ID));
+    assertThat(abbreviateName(AMBIGUOUS_TREE_ID)).isEqualTo(abbreviateName(ID));
+  }
+
+  @Test
+  public void abbreviateNameCustomLength() throws Exception {
+    assertRuntimeException(() -> abbreviateName(null, 1));
+    assertRuntimeException(() -> abbreviateName(ID, -1));
+    assertRuntimeException(() -> abbreviateName(ID, 0));
+    assertRuntimeException(() -> abbreviateName(ID, 41));
+    assertThat(abbreviateName(ID, 5)).isEqualTo("00000");
+    assertThat(abbreviateName(ID, 40)).isEqualTo(ID.name());
+  }
+
+  @Test
+  public void abbreviateNameDefaultLengthWithReader() throws Exception {
+    assertRuntimeException(() -> abbreviateName(ID, null));
+
+    ObjectReader reader = newReaderWithAmbiguousIds();
+    assertThat(abbreviateName(ID, reader)).isEqualTo("00000000001");
+  }
+
+  @Test
+  public void abbreviateNameCustomLengthWithReader() throws Exception {
+    ObjectReader reader = newReaderWithAmbiguousIds();
+    assertRuntimeException(() -> abbreviateName(ID, -1, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 0, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 41, reader));
+    assertRuntimeException(() -> abbreviateName(ID, 5, null));
+
+    String shortest = "00000000001";
+    assertThat(abbreviateName(ID, 1, reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, 7, reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, shortest.length(), reader)).isEqualTo(shortest);
+    assertThat(abbreviateName(ID, shortest.length() + 1, reader)).isEqualTo("000000000010");
+  }
+
+  @Test
+  public void copyOrNull() throws Exception {
+    testCopy(ObjectIds::copyOrNull);
+    assertThat(ObjectIds.copyOrNull(null)).isNull();
+  }
+
+  @Test
+  public void copyOrZero() throws Exception {
+    testCopy(ObjectIds::copyOrZero);
+    assertThat(ObjectIds.copyOrZero(null)).isEqualTo(ObjectId.zeroId());
+  }
+
+  private void testCopy(Function<AnyObjectId, ObjectId> copyFunc) {
+    MyObjectId myId = new MyObjectId(ID);
+    assertThat(myId).isEqualTo(ID);
+
+    ObjectId copy = copyFunc.apply(myId);
+    assertThat(copy).isEqualTo(myId);
+    assertThat(copy).isNotSameInstanceAs(myId);
+    assertThat(copy.getClass()).isEqualTo(ObjectId.class);
+  }
+
+  @Test
+  public void matchesAbbreviation() throws Exception {
+    assertThat(ObjectIds.matchesAbbreviation(null, "")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "0")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "00000")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, "not a SHA-1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(null, ID.name())).isFalse();
+
+    assertThat(ObjectIds.matchesAbbreviation(ID, "")).isTrue();
+    for (int i = 1; i <= OBJECT_ID_STRING_LENGTH; i++) {
+      String prefix = ID.name().substring(0, i);
+      assertThat(ObjectIds.matchesAbbreviation(ID, prefix))
+          .named("match %s against %s", ID.name(), prefix)
+          .isTrue();
+    }
+
+    assertThat(ObjectIds.matchesAbbreviation(ID, "1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, "x")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, "not a SHA-1")).isFalse();
+    assertThat(ObjectIds.matchesAbbreviation(ID, AMBIGUOUS_BLOB_ID.name())).isFalse();
+  }
+
+  @FunctionalInterface
+  private interface Func {
+    void call() throws Exception;
+  }
+
+  private static void assertRuntimeException(Func func) throws Exception {
+    try {
+      func.call();
+      assert_().fail("Expected RuntimeException");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+
+  private static ObjectReader newReaderWithAmbiguousIds() throws Exception {
+    // Recipe for creating ambiguous IDs courtesy of git core:
+    // https://github.com/git/git/blob/df799f5d99ac51d4fc791d546de3f936088582fc/t/t1512-rev-parse-disambiguation.sh
+    TestRepository<?> tr =
+        new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")));
+    String blobData = "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n\nb1rwzyc3\n";
+    RevBlob blob = tr.blob(blobData);
+    assertThat(blob.name()).isEqualTo(AMBIGUOUS_BLOB_ID.name());
+    assertThat(tr.tree(tr.file("a0blgqsjc", blob)).name()).isEqualTo(AMBIGUOUS_TREE_ID.name());
+    return tr.getRevWalk().getObjectReader();
+  }
+
+  private static class MyObjectId extends ObjectId {
+    private static final long serialVersionUID = 1L;
+
+    MyObjectId(AnyObjectId src) {
+      super(src);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
index cdc94c2..99247b8 100644
--- a/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.MoreFiles;
 import com.google.common.io.RecursiveDeleteOption;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -36,7 +35,7 @@
 import org.junit.runners.Parameterized.Parameters;
 
 @RunWith(Parameterized.class)
-public class RefUpdateUtilRepoTest extends GerritBaseTests {
+public class RefUpdateUtilRepoTest {
   public enum RepoSetup {
     LOCAL_DISK {
       @Override
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
index 429583a..fe40fb4 100644
--- a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
@@ -19,7 +19,6 @@
 import static com.google.common.truth.Truth.assert_;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.util.function.Consumer;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -33,7 +32,7 @@
 import org.junit.runners.JUnit4;
 
 @RunWith(JUnit4.class)
-public class RefUpdateUtilTest extends GerritBaseTests {
+public class RefUpdateUtilTest {
   private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
   private static final Consumer<ReceiveCommand> LOCK_FAILURE =
       c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
diff --git a/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java b/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java
index 5ab52d4..3bf815b 100644
--- a/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java
+++ b/javatests/com/google/gerrit/git/testing/PushResultSubjectTest.java
@@ -18,10 +18,9 @@
 import static com.google.gerrit.git.testing.PushResultSubject.parseProcessed;
 import static com.google.gerrit.git.testing.PushResultSubject.trimMessages;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class PushResultSubjectTest extends GerritBaseTests {
+public class PushResultSubjectTest {
   @Test
   public void testTrimMessages() {
     assertThat(trimMessages(null)).isNull();
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 220361e..bc035af 100644
--- a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -41,7 +41,6 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -64,7 +63,7 @@
 import org.junit.Test;
 
 /** Unit tests for {@link GerritPublicKeyChecker}. */
-public class GerritPublicKeyCheckerTest extends GerritBaseTests {
+public class GerritPublicKeyCheckerTest {
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdateProvider;
 
   @Inject private AccountManager accountManager;
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 145b9cf..7703fb0 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -38,7 +38,6 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testing.TestKey;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -60,7 +59,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class PublicKeyCheckerTest extends GerritBaseTests {
+public class PublicKeyCheckerTest {
   private InMemoryRepository repo;
   private PublicKeyStore store;
 
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
index be65752..3727d38 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -29,7 +29,6 @@
 
 import com.google.common.collect.Iterators;
 import com.google.gerrit.gpg.testing.TestKey;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Iterator;
@@ -51,7 +50,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class PublicKeyStoreTest extends GerritBaseTests {
+public class PublicKeyStoreTest {
   private TestRepository<?> tr;
   private PublicKeyStore store;
 
diff --git a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
index 67bf050..266f868 100644
--- a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
@@ -23,7 +23,6 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testing.TestKey;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStreamReader;
@@ -54,7 +53,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class PushCertificateCheckerTest extends GerritBaseTests {
+public class PushCertificateCheckerTest {
   private InMemoryRepository repo;
   private PublicKeyStore store;
   private SignedPushConfig signedPushConfig;
diff --git a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index e2c58d8..1c6559b0 100644
--- a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.server.plugins.Plugin;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import com.google.inject.Key;
@@ -36,7 +35,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class AllRequestFilterFilterProxyTest extends GerritBaseTests {
+public class AllRequestFilterFilterProxyTest {
   /**
    * Set of filters for FilterProxy
    *
diff --git a/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java b/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
index e19085d..f012ee3 100644
--- a/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.httpd.RemoteUserUtil.extractUsername;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class RemoteUserUtilTest extends GerritBaseTests {
+public class RemoteUserUtilTest {
   @Test
   public void testExtractUsername() {
     assertThat(extractUsername(null)).isNull();
diff --git a/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java b/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
index 2de3788..684a241 100644
--- a/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
+++ b/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import org.junit.Test;
 
-public class ContextMapperTest extends GerritBaseTests {
+public class ContextMapperTest {
 
   private static final String CONTEXT = "/context";
   private static final String PLUGIN_NAME = "my-plugin";
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index b4f8e7a..307a23e 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.template.soy.data.SoyMapData;
 import java.net.URISyntaxException;
 import org.junit.Test;
 
-public class IndexServletTest extends GerritBaseTests {
+public class IndexServletTest {
   static class TestIndexServlet extends IndexServlet {
     private static final long serialVersionUID = 1L;
 
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index cfcc1d0..dd594d6 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
@@ -28,7 +29,6 @@
 import com.google.common.jimfs.Configuration;
 import com.google.common.jimfs.Jimfs;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import java.io.ByteArrayInputStream;
@@ -45,7 +45,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ResourceServletTest extends GerritBaseTests {
+public class ResourceServletTest {
   private static Cache<Path, Resource> newCache(int size) {
     return CacheBuilder.newBuilder().maximumSize(size).recordStats().build();
   }
@@ -336,8 +336,8 @@
   }
 
   private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
-    assertThat(cache.stats().hitCount()).named("hits").isEqualTo(hits);
-    assertThat(cache.stats().missCount()).named("misses").isEqualTo(misses);
+    assertWithMessage("hits").that(cache.stats().hitCount()).isEqualTo(hits);
+    assertWithMessage("misses").that(cache.stats().missCount()).isEqualTo(misses);
   }
 
   private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
diff --git a/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java b/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
index fa3eaea..fb1ebd9 100644
--- a/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
+++ b/javatests/com/google/gerrit/httpd/restapi/HttpLogRedactTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class HttpLogRedactTest extends GerritBaseTests {
+public class HttpLogRedactTest {
   @Test
   public void redactAuth() {
     assertThat(LogRedactUtil.redactQueryString("query=status:open")).isEqualTo("query=status:open");
diff --git a/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java b/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
index 30d318b..a550ac7 100644
--- a/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
+++ b/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
@@ -15,21 +15,20 @@
 package com.google.gerrit.httpd.restapi;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 import org.junit.Test;
 
-public class ParameterParserTest extends GerritBaseTests {
+public class ParameterParserTest {
   @Test
   public void convertFormToJson() throws BadRequestException {
     JsonObject obj =
@@ -110,35 +109,26 @@
   public void rejectDuplicateMethod() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$m=PUT&$m=DELETE");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
   }
 
   @Test
   public void rejectDuplicateContentType() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$ct=json&$ct=string");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
   }
 
   @Test
   public void rejectInvalidMethod() {
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.setQueryString("$m=CONNECT");
-    try {
-      ParameterParser.getQueryParams(req);
-      fail("expected BadRequestException");
-    } catch (BadRequestException bad) {
-      assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
-    }
+    BadRequestException bad =
+        assertThrows(BadRequestException.class, () -> ParameterParser.getQueryParams(req));
+    assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
   }
 }
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index d6b8421..698e00a 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -18,13 +18,13 @@
 import static com.google.gerrit.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.index.SchemaUtil.schema;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
-public class SchemaUtilTest extends GerritBaseTests {
+public class SchemaUtilTest {
   static class TestSchemas {
     static final Schema<String> V1 = schema();
     static final Schema<String> V2 = schema();
@@ -43,9 +43,9 @@
     assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
     assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
     assertThat(all.get(4)).isEqualTo(TestSchemas.V4);
-
-    exception.expect(IllegalArgumentException.class);
-    SchemaUtil.schemasFromClass(TestSchemas.class, Object.class);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> SchemaUtil.schemasFromClass(TestSchemas.class, Object.class));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index 21098b3..16828dd 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.List;
 import org.junit.Test;
@@ -43,28 +43,13 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = and(a, b);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
     assertChildren("clear", n, of(a, b));
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
     assertChildren("remove(0)", n, of(a, b));
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
diff --git a/javatests/com/google/gerrit/index/query/FieldPredicateTest.java b/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
index 805f31c..2d2c99e 100644
--- a/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertSame;
@@ -61,8 +63,9 @@
     assertSame(f, f.copy(Collections.emptyList()));
     assertSame(f, f.copy(f.getChildren()));
 
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("Expected 0 children");
-    f.copy(Collections.singleton(f("owner", "bob")));
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> f.copy(Collections.singleton(f("owner", "bob"))));
+    assertThat(thrown).hasMessageThat().contains("Expected 0 children");
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/NotPredicateTest.java b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
index d10d2df..3d1839d 100644
--- a/javatests/com/google/gerrit/index/query/NotPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.not;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.Collections;
 import java.util.List;
@@ -50,26 +50,14 @@
     final TestPredicate p = f("author", "bob");
     final Predicate<String> n = not(p);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("clear", p, n);
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
+    assertOnlyChild("clear", p, n);
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("remove(0)", p, n);
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
+    assertOnlyChild("remove(0)", p, n);
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("remove()", p, n);
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
+    assertOnlyChild("remove()", p, n);
   }
 
   private static void assertOnlyChild(String o, Predicate<String> c, Predicate<String> p) {
@@ -112,18 +100,11 @@
     assertNotSame(n, n.copy(sb));
     assertEquals(sb, n.copy(sb).getChildren());
 
-    try {
-      n.copy(Collections.emptyList());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> n.copy(Collections.emptyList()));
+    assertEquals("Expected exactly one child", e.getMessage());
 
-    try {
-      n.copy(and(a, b).getChildren());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
+    e = assertThrows(IllegalArgumentException.class, () -> n.copy(and(a, b).getChildren()));
+    assertEquals("Expected exactly one child", e.getMessage());
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index 255a3f8..1cbcb75 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.List;
 import org.junit.Test;
@@ -43,28 +43,13 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = or(a, b);
 
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
     assertChildren("clear", n, of(a, b));
 
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
     assertChildren("remove(0)", n, of(a, b));
 
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
+    assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
diff --git a/javatests/com/google/gerrit/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
index 2295a60..3ec7f13 100644
--- a/javatests/com/google/gerrit/index/query/PredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/PredicateTest.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Ignore;
 
 @Ignore
-public abstract class PredicateTest extends GerritBaseTests {
+public abstract class PredicateTest {
   protected static final class TestPredicate extends OperatorPredicate<String> {
     protected TestPredicate(String name, String value) {
       super(name, value);
diff --git a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
index 6a397dc..f653759 100644
--- a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.truth.ThrowableSubject;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collection;
 import java.util.Objects;
 import org.junit.Test;
 
-public class QueryBuilderTest extends GerritBaseTests {
+public class QueryBuilderTest {
   private static class TestPredicate extends Predicate<Object> {
     private final String field;
     private final String value;
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index b4dd1ee..f315da5 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -23,11 +23,10 @@
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.antlr.runtime.tree.Tree;
 import org.junit.Test;
 
-public class QueryParserTest extends GerritBaseTests {
+public class QueryParserTest {
   @Test
   public void fieldNameAndValue() throws Exception {
     Tree r = parse("project:tools/gerrit");
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index bcff6a7..c7359f3 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -18,7 +18,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -26,7 +25,7 @@
 import org.junit.Ignore;
 
 @Ignore
-public class AbstractParserTest extends GerritBaseTests {
+public class AbstractParserTest {
   protected static final String CHANGE_URL =
       "https://gerrit-review.googlesource.com/c/project/+/123";
 
@@ -56,7 +55,7 @@
     Comment c =
         new Comment(
             new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
+            Account.id(0),
             new Timestamp(0L),
             (short) 0,
             message,
@@ -70,7 +69,7 @@
     Comment c =
         new Comment(
             new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
+            Account.id(0),
             new Timestamp(0L),
             (short) 0,
             message,
diff --git a/javatests/com/google/gerrit/mail/AddressTest.java b/javatests/com/google/gerrit/mail/AddressTest.java
index 53ff1fe..5607ae9 100644
--- a/javatests/com/google/gerrit/mail/AddressTest.java
+++ b/javatests/com/google/gerrit/mail/AddressTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.fail;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class AddressTest extends GerritBaseTests {
+public class AddressTest {
   @Test
   public void parse_NameEmail1() {
     final Address a = Address.parse("A U Thor <author@example.com>");
diff --git a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
index cdc8d7a..2d2c2ea 100644
--- a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -16,14 +16,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
 import org.junit.Test;
 
-public class MailHeaderParserTest extends GerritBaseTests {
+public class MailHeaderParserTest {
   @Test
   public void parseMetadataFromHeader() {
     // This tests if the metadata parser is able to parse metadata from the
diff --git a/javatests/com/google/gerrit/mail/ParserUtilTest.java b/javatests/com/google/gerrit/mail/ParserUtilTest.java
index ed40a57..47a5367 100644
--- a/javatests/com/google/gerrit/mail/ParserUtilTest.java
+++ b/javatests/com/google/gerrit/mail/ParserUtilTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class ParserUtilTest extends GerritBaseTests {
+public class ParserUtilTest {
   @Test
   public void trimQuotationLineOnMessageWithoutQuoatationLine() throws Exception {
     assertThat(ParserUtil.trimQuotation("One line")).isEqualTo("One line");
diff --git a/javatests/com/google/gerrit/mail/RawMailParserTest.java b/javatests/com/google/gerrit/mail/RawMailParserTest.java
index 9049704..0ab2811 100644
--- a/javatests/com/google/gerrit/mail/RawMailParserTest.java
+++ b/javatests/com/google/gerrit/mail/RawMailParserTest.java
@@ -23,10 +23,9 @@
 import com.google.gerrit.mail.data.QuotedPrintableHeaderMessage;
 import com.google.gerrit.mail.data.RawMailMessage;
 import com.google.gerrit.mail.data.SimpleTextMessage;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class RawMailParserTest extends GerritBaseTests {
+public class RawMailParserTest {
   @Test
   public void parseEmail() throws Exception {
     RawMailMessage[] messages =
diff --git a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
index a8f5b94..c4737e6 100644
--- a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
+++ b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
@@ -39,7 +39,7 @@
           + "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"
+          + "  com.google.gerrit.exceptions.StorageException: 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"
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
index d6bcb62..9b21bf6 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
+++ b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class DropWizardMetricMakerTest extends GerritBaseTests {
+public class DropWizardMetricMakerTest {
   DropWizardMetricMaker metrics =
       new DropWizardMetricMaker(null /* MetricRegistry unused in tests */);
 
diff --git a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 0a5dabf..db75cd8 100644
--- a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.metrics.proc;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.codahale.metrics.Counter;
 import com.codahale.metrics.Gauge;
@@ -30,7 +32,6 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -39,7 +40,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ProcMetricModuleTest extends GerritBaseTests {
+public class ProcMetricModuleTest {
   @Inject MetricMaker metrics;
 
   @Inject MetricRegistry registry;
@@ -147,14 +148,16 @@
 
   @Test
   public void invalidName1() {
-    exception.expect(IllegalArgumentException.class);
-    metrics.newCounter("invalid name", new Description("fail"));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> metrics.newCounter("invalid name", new Description("fail")));
   }
 
   @Test
   public void invalidName2() {
-    exception.expect(IllegalArgumentException.class);
-    metrics.newCounter("invalid/ name", new Description("fail"));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> metrics.newCounter("invalid/ name", new Description("fail")));
   }
 
   @SuppressWarnings({"unchecked", "cast"})
@@ -164,8 +167,8 @@
 
   private <M extends Metric> M get(String name, Class<M> type) {
     Metric m = registry.getMetrics().get(name);
-    assertThat(m).named(name).isNotNull();
-    assertThat(m).named(name).isInstanceOf(type);
+    assertWithMessage(name).that(m).isNotNull();
+    assertWithMessage(name).that(m).isInstanceOf(type);
 
     @SuppressWarnings("unchecked")
     M result = (M) m;
diff --git a/javatests/com/google/gerrit/pgm/init/LibrariesTest.java b/javatests/com/google/gerrit/pgm/init/LibrariesTest.java
index 47b7509..543f765 100644
--- a/javatests/com/google/gerrit/pgm/init/LibrariesTest.java
+++ b/javatests/com/google/gerrit/pgm/init/LibrariesTest.java
@@ -21,12 +21,11 @@
 
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.nio.file.Paths;
 import java.util.Collections;
 import org.junit.Test;
 
-public class LibrariesTest extends GerritBaseTests {
+public class LibrariesTest {
   @Test
   public void create() throws Exception {
     final SitePaths site = new SitePaths(Paths.get("."));
diff --git a/javatests/com/google/gerrit/proto/ProtosTest.java b/javatests/com/google/gerrit/proto/ProtosTest.java
index 29e8fe0..edaca54 100644
--- a/javatests/com/google/gerrit/proto/ProtosTest.java
+++ b/javatests/com/google/gerrit/proto/ProtosTest.java
@@ -19,12 +19,11 @@
 
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.ByteString;
 import java.util.Arrays;
 import org.junit.Test;
 
-public class ProtosTest extends GerritBaseTests {
+public class ProtosTest {
   @Test
   public void parseUncheckedByteArrayWrongProtoType() {
     ChangeNotesKeyProto proto =
diff --git a/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java b/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
index 18a55bf..1332171 100644
--- a/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRef;
 import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRefPart;
+import static com.google.gerrit.reviewdb.client.AccountGroup.uuid;
 
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -70,7 +71,29 @@
     assertThat(fromRefPart("ab/" + TEST_UUID)).isNull();
   }
 
-  private AccountGroup.UUID uuid(String uuid) {
-    return new AccountGroup.UUID(uuid);
+  @Test
+  public void uuidToString() {
+    assertThat(uuid("foo").toString()).isEqualTo("foo");
+    assertThat(uuid("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(uuid("foo:bar").toString()).isEqualTo("foo%3Abar");
+  }
+
+  @Test
+  public void parseUuid() {
+    assertThat(AccountGroup.UUID.parse("foo")).isEqualTo(uuid("foo"));
+    assertThat(AccountGroup.UUID.parse("foo+bar")).isEqualTo(uuid("foo bar"));
+    assertThat(AccountGroup.UUID.parse("foo%3Abar")).isEqualTo(uuid("foo:bar"));
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(AccountGroup.id(123).toString()).isEqualTo("123");
+  }
+
+  @Test
+  public void nameKeyToString() {
+    assertThat(AccountGroup.nameKey("foo").toString()).isEqualTo("foo");
+    assertThat(AccountGroup.nameKey("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(AccountGroup.nameKey("foo:bar").toString()).isEqualTo("foo%3Abar");
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/client/AccountTest.java b/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
index 11a562f..e8ab613 100644
--- a/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.reviewdb.client.Account.Id.fromRef;
 import static com.google.gerrit.reviewdb.client.Account.Id.fromRefPart;
 import static com.google.gerrit.reviewdb.client.Account.Id.fromRefSuffix;
+import static com.google.gerrit.reviewdb.client.Account.id;
 
 import org.junit.Test;
 
@@ -90,8 +91,4 @@
     assertThat(fromRefSuffix("12/34")).isEqualTo(id(34));
     assertThat(fromRefSuffix("ab/cd")).isNull();
   }
-
-  private Account.Id id(int n) {
-    return new Account.Id(n);
-  }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/client/BUILD b/javatests/com/google/gerrit/reviewdb/client/BUILD
index bc993d5..391d80e 100644
--- a/javatests/com/google/gerrit/reviewdb/client/BUILD
+++ b/javatests/com/google/gerrit/reviewdb/client/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/reviewdb/client/BranchTest.java b/javatests/com/google/gerrit/reviewdb/client/BranchTest.java
new file mode 100644
index 0000000..ac99a1a
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/BranchTest.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class BranchTest {
+  @Test
+  public void canonicalizeNameDuringConstruction() {
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "bar").branch())
+        .isEqualTo("refs/heads/bar");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "refs/heads/bar").branch())
+        .isEqualTo("refs/heads/bar");
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(BranchNameKey.create(new Project.NameKey("foo"), "bar").toString())
+        .isEqualTo("foo,refs/heads/bar");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo bar"), "bar baz").toString())
+        .isEqualTo("foo+bar,refs/heads/bar+baz");
+    assertThat(BranchNameKey.create(new Project.NameKey("foo^bar"), "bar^baz").toString())
+        .isEqualTo("foo%5Ebar,refs/heads/bar%5Ebaz");
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java b/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
index 6d1d0a6..ccc0bd2 100644
--- a/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
@@ -18,6 +18,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class ChangeTest {
@@ -122,8 +123,8 @@
 
   @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(Change.id(1).toRefPrefix()).isEqualTo("refs/changes/01/1/");
+    assertThat(Change.id(1234).toRefPrefix()).isEqualTo("refs/changes/34/1234/");
   }
 
   @Test
@@ -147,8 +148,20 @@
     assertNotRefPart("1/1");
   }
 
+  @Test
+  public void idToString() {
+    assertThat(Change.id(3).toString()).isEqualTo("3");
+  }
+
+  @Test
+  public void keyToString() {
+    String key = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    assertThat(ObjectId.isId(key.substring(1))).isTrue();
+    assertThat(Change.key(key).toString()).isEqualTo(key);
+  }
+
   private static void assertRef(int changeId, String refName) {
-    assertThat(Change.Id.fromRef(refName)).isEqualTo(new Change.Id(changeId));
+    assertThat(Change.Id.fromRef(refName)).isEqualTo(Change.id(changeId));
   }
 
   private static void assertNotRef(String refName) {
@@ -156,7 +169,7 @@
   }
 
   private static void assertAllUsersRef(int changeId, String refName) {
-    assertThat(Change.Id.fromAllUsersRef(refName)).isEqualTo(new Change.Id(changeId));
+    assertThat(Change.Id.fromAllUsersRef(refName)).isEqualTo(Change.id(changeId));
   }
 
   private static void assertNotAllUsersRef(String refName) {
@@ -164,7 +177,7 @@
   }
 
   private static void assertRefPart(int changeId, String refName) {
-    assertEquals(new Change.Id(changeId), Change.Id.fromRefPart(refName));
+    assertEquals(Change.id(changeId), Change.Id.fromRefPart(refName));
   }
 
   private static void assertNotRefPart(String refName) {
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
index 5e42ce0..c73f327 100644
--- a/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
@@ -16,23 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.HashMap;
 import java.util.Map;
 import org.junit.Test;
 
-public class PatchSetApprovalTest extends GerritBaseTests {
+public class PatchSetApprovalTest {
   @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(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("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(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("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(
+            PatchSet.id(Change.id(1), 2), Account.id(3), LabelId.create("Other-Label"));
 
     assertThat(k2).isEqualTo(k1);
     assertThat(k3).isNotEqualTo(k1);
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
index 51a405f..2167bcd 100644
--- a/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.reviewdb.client;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.reviewdb.client.PatchSet.joinGroups;
 import static com.google.gerrit.reviewdb.client.PatchSet.splitGroups;
 
@@ -64,37 +65,72 @@
 
   @Test
   public void testSplitGroups() {
+    assertRuntimeException(() -> splitGroups(null));
     assertThat(splitGroups("")).containsExactly("");
     assertThat(splitGroups("abcd")).containsExactly("abcd");
     assertThat(splitGroups("ab,cd")).containsExactly("ab", "cd").inOrder();
+    assertThat(splitGroups("ab , cd")).containsExactly("ab ", " cd").inOrder();
     assertThat(splitGroups("ab,")).containsExactly("ab", "").inOrder();
     assertThat(splitGroups(",cd")).containsExactly("", "cd").inOrder();
   }
 
   @Test
   public void testJoinGroups() {
+    assertRuntimeException(() -> joinGroups(null));
+    assertRuntimeException(() -> joinGroups(ImmutableList.of("a,", "b")));
     assertThat(joinGroups(ImmutableList.of(""))).isEqualTo("");
     assertThat(joinGroups(ImmutableList.of("abcd"))).isEqualTo("abcd");
     assertThat(joinGroups(ImmutableList.of("ab", "cd"))).isEqualTo("ab,cd");
+    assertThat(joinGroups(ImmutableList.of("ab ", " cd"))).isEqualTo("ab , cd");
     assertThat(joinGroups(ImmutableList.of("ab", ""))).isEqualTo("ab,");
     assertThat(joinGroups(ImmutableList.of("", "cd"))).isEqualTo(",cd");
   }
 
   @Test
   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");
+    assertThat(PatchSet.id(Change.id(1), 23).toRefName()).isEqualTo("refs/changes/01/1/23");
+    assertThat(PatchSet.id(Change.id(1234), 5).toRefName()).isEqualTo("refs/changes/34/1234/5");
+  }
+
+  @Test
+  public void parseId() {
+    assertThat(PatchSet.Id.parse("1,2")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    assertThat(PatchSet.Id.parse("01,02")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    assertInvalidId(null);
+    assertInvalidId("");
+    assertInvalidId("1");
+    assertInvalidId("1,foo.txt");
+    assertInvalidId("foo.txt,1");
+
+    String hexComma = "%" + String.format("%02x", (int) ',');
+    assertInvalidId("1" + hexComma + "2");
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(PatchSet.id(Change.id(2), 3).toString()).isEqualTo("2,3");
   }
 
   private static void assertRef(int changeId, int psId, String refName) {
     assertThat(PatchSet.isChangeRef(refName)).isTrue();
-    assertThat(PatchSet.Id.fromRef(refName))
-        .isEqualTo(new PatchSet.Id(new Change.Id(changeId), psId));
+    assertThat(PatchSet.Id.fromRef(refName)).isEqualTo(PatchSet.id(Change.id(changeId), psId));
   }
 
   private static void assertNotRef(String refName) {
     assertThat(PatchSet.isChangeRef(refName)).isFalse();
     assertThat(PatchSet.Id.fromRef(refName)).isNull();
   }
+
+  private static void assertInvalidId(String str) {
+    assertRuntimeException(() -> PatchSet.Id.parse(str));
+  }
+
+  private static void assertRuntimeException(Runnable runnable) {
+    try {
+      runnable.run();
+      assert_().fail("expected RuntimeException");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchTest.java
new file mode 100644
index 0000000..2939a9e
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/PatchTest.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import org.junit.Test;
+
+public class PatchTest {
+  @Test
+  public void isMagic() {
+    assertThat(Patch.isMagic("/COMMIT_MSG")).isTrue();
+    assertThat(Patch.isMagic("/MERGE_LIST")).isTrue();
+
+    assertThat(Patch.isMagic("/COMMIT_MSG/")).isFalse();
+    assertThat(Patch.isMagic("COMMIT_MSG")).isFalse();
+    assertThat(Patch.isMagic("/commit_msg")).isFalse();
+  }
+
+  @Test
+  public void parseKey() {
+    assertThat(Patch.Key.parse("1,2,foo.txt"))
+        .isEqualTo(Patch.key(PatchSet.id(Change.id(1), 2), "foo.txt"));
+    assertThat(Patch.Key.parse("01,02,foo.txt"))
+        .isEqualTo(Patch.key(PatchSet.id(Change.id(1), 2), "foo.txt"));
+    assertInvalidKey(null);
+    assertInvalidKey("");
+    assertInvalidKey("1,2");
+    assertInvalidKey("1, 2, foo.txt");
+    assertInvalidKey("1,foo.txt");
+    assertInvalidKey("1,foo.txt,2");
+    assertInvalidKey("foo.txt,1,2");
+
+    String hexComma = "%" + String.format("%02x", (int) ',');
+    assertInvalidKey("1" + hexComma + "2" + hexComma + "foo.txt");
+  }
+
+  private static void assertInvalidKey(String str) {
+    try {
+      Patch.Key.parse(str);
+      assert_().fail("expected RuntimeException");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/ProjectTest.java b/javatests/com/google/gerrit/reviewdb/client/ProjectTest.java
new file mode 100644
index 0000000..a24cff7
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/ProjectTest.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ProjectTest {
+  @Test
+  public void parseId() {
+    assertThat(Project.NameKey.parse("foo")).isEqualTo(new Project.NameKey("foo"));
+    assertThat(Project.NameKey.parse("foo%20bar")).isEqualTo(new Project.NameKey("foo bar"));
+    assertThat(Project.NameKey.parse("foo+bar")).isEqualTo(new Project.NameKey("foo bar"));
+    assertThat(Project.NameKey.parse("foo%2fbar")).isEqualTo(new Project.NameKey("foo/bar"));
+    assertThat(Project.NameKey.parse("foo%2Fbar")).isEqualTo(new Project.NameKey("foo/bar"));
+  }
+
+  @Test
+  public void idToString() {
+    assertThat(Project.nameKey("foo").toString()).isEqualTo("foo");
+    assertThat(Project.nameKey("foo bar").toString()).isEqualTo("foo+bar");
+    assertThat(Project.nameKey("foo/bar").toString()).isEqualTo("foo/bar");
+    assertThat(Project.nameKey("foo^bar").toString()).isEqualTo("foo%5Ebar");
+    assertThat(Project.nameKey("foo%bar").toString()).isEqualTo("foo%25bar");
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
index fa6a722..7f22275 100644
--- a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -20,21 +20,17 @@
 import static com.google.gerrit.reviewdb.client.RefNames.parseShardedRefPart;
 import static com.google.gerrit.reviewdb.client.RefNames.parseShardedUuidFromRefPart;
 import static com.google.gerrit.reviewdb.client.RefNames.skipShardedRefPart;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class RefNamesTest {
   private static final String TEST_GROUP_UUID = "ccab3195282a8ce4f5014efa391e82d10f884c64";
   private static final String TEST_SHARDED_GROUP_UUID =
       TEST_GROUP_UUID.substring(0, 2) + "/" + TEST_GROUP_UUID;
-
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
-  private final Account.Id accountId = new Account.Id(1011123);
-  private final Change.Id changeId = new Change.Id(67473);
-  private final PatchSet.Id psId = new PatchSet.Id(changeId, 42);
+  private final Account.Id accountId = Account.id(1011123);
+  private final Change.Id changeId = Change.id(67473);
+  private final PatchSet.Id psId = PatchSet.id(changeId, 42);
 
   @Test
   public void fullName() throws Exception {
@@ -58,30 +54,28 @@
 
   @Test
   public void refForGroupIsSharded() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("ABCDEFG");
     String groupRef = RefNames.refsGroups(groupUuid);
     assertThat(groupRef).isEqualTo("refs/groups/AB/ABCDEFG");
   }
 
   @Test
   public void refForGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
-    expectedException.expect(IllegalArgumentException.class);
-    RefNames.refsGroups(groupUuid);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("A");
+    assertThrows(IllegalArgumentException.class, () -> RefNames.refsGroups(groupUuid));
   }
 
   @Test
   public void refForDeletedGroupIsSharded() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("ABCDEFG");
     String groupRef = RefNames.refsDeletedGroups(groupUuid);
     assertThat(groupRef).isEqualTo("refs/deleted-groups/AB/ABCDEFG");
   }
 
   @Test
   public void refForDeletedGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
-    expectedException.expect(IllegalArgumentException.class);
-    RefNames.refsDeletedGroups(groupUuid);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid("A");
+    assertThrows(IllegalArgumentException.class, () -> RefNames.refsDeletedGroups(groupUuid));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
index 123a973..18ce0fe 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/AccountIdProtoConverterTest.java
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Account.Id accountId = new Account.Id(24);
+    Account.Id accountId = Account.id(24);
 
     Entities.Account_Id proto = accountIdProtoConverter.toProto(accountId);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Account.Id accountId = new Account.Id(34832);
+    Account.Id accountId = Account.id(34832);
 
     Account.Id convertedAccountId =
         accountIdProtoConverter.fromProto(accountIdProtoConverter.toProto(accountId));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Account.Id.class).hasFields(ImmutableMap.of("id", int.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Account.Id.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", int.class));
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/BUILD b/javatests/com/google/gerrit/reviewdb/converter/BUILD
index 9cc941c..e745344 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/BUILD
+++ b/javatests/com/google/gerrit/reviewdb/converter/BUILD
@@ -8,6 +8,8 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//lib:guava",
         "//lib:protobuf",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
         "//proto:entities_java_proto",
diff --git a/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java
index 412641f..2f6bb61 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/BranchNameKeyProtoConverterTest.java
@@ -21,7 +21,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
@@ -33,23 +33,23 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Branch.NameKey nameKey = new Branch.NameKey(new Project.NameKey("project-13"), "branch-72");
+    BranchNameKey nameKey = BranchNameKey.create(Project.nameKey("project-13"), "branch-72");
 
     Entities.Branch_NameKey proto = branchNameKeyProtoConverter.toProto(nameKey);
 
     Entities.Branch_NameKey expectedProto =
         Entities.Branch_NameKey.newBuilder()
-            .setProjectName(Entities.Project_NameKey.newBuilder().setName("project-13"))
-            .setBranchName("refs/heads/branch-72")
+            .setProject(Entities.Project_NameKey.newBuilder().setName("project-13"))
+            .setBranch("refs/heads/branch-72")
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Branch.NameKey nameKey = new Branch.NameKey(new Project.NameKey("project-52"), "branch 14");
+    BranchNameKey nameKey = BranchNameKey.create(Project.nameKey("project-52"), "branch 14");
 
-    Branch.NameKey convertedNameKey =
+    BranchNameKey convertedNameKey =
         branchNameKeyProtoConverter.fromProto(branchNameKeyProtoConverter.toProto(nameKey));
 
     assertThat(convertedNameKey).isEqualTo(nameKey);
@@ -59,8 +59,8 @@
   public void protoCanBeParsedFromBytes() throws Exception {
     Entities.Branch_NameKey proto =
         Entities.Branch_NameKey.newBuilder()
-            .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 1"))
-            .setBranchName("branch 36")
+            .setProject(Entities.Project_NameKey.newBuilder().setName("project 1"))
+            .setBranch("branch 36")
             .build();
     byte[] bytes = proto.toByteArray();
 
@@ -72,12 +72,12 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Branch.NameKey.class)
-        .hasFields(
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(BranchNameKey.class)
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
-                .put("projectName", Project.NameKey.class)
-                .put("branchName", String.class)
+                .put("project", Project.NameKey.class)
+                .put("branch", String.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
index d5ebb51..ee5d3ff 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeIdProtoConverterTest.java
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Change.Id changeId = new Change.Id(94);
+    Change.Id changeId = Change.id(94);
 
     Entities.Change_Id proto = changeIdProtoConverter.toProto(changeId);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Change.Id changeId = new Change.Id(2903482);
+    Change.Id changeId = Change.id(2903482);
 
     Change.Id convertedChangeId =
         changeIdProtoConverter.fromProto(changeIdProtoConverter.toProto(changeId));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Change.Id.class).hasFields(ImmutableMap.of("id", int.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Change.Id.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", int.class));
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java
index d948706..8bcdd49 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeKeyProtoConverterTest.java
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Change.Key changeKey = new Change.Key("change-1");
+    Change.Key changeKey = Change.key("change-1");
 
     Entities.Change_Key proto = changeKeyProtoConverter.toProto(changeKey);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Change.Key changeKey = new Change.Key("change-52");
+    Change.Key changeKey = Change.key("change-52");
 
     Change.Key convertedChangeKey =
         changeKeyProtoConverter.fromProto(changeKeyProtoConverter.toProto(changeKey));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(Change.Key.class).hasFields(ImmutableMap.of("id", String.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(Change.Key.class)
+        .hasAutoValueMethods(ImmutableMap.of("key", String.class));
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
index c8bb2ed..ed4e887 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
@@ -33,7 +33,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+    ChangeMessage.Key messageKey = ChangeMessage.key(Change.id(704), "aabbcc");
 
     Entities.ChangeMessage_Key proto = messageKeyProtoConverter.toProto(messageKey);
 
@@ -47,7 +47,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+    ChangeMessage.Key messageKey = ChangeMessage.key(Change.id(704), "aabbcc");
 
     ChangeMessage.Key convertedMessageKey =
         messageKeyProtoConverter.fromProto(messageKeyProtoConverter.toProto(messageKey));
@@ -72,9 +72,9 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
+  public void methodsExistAsExpected() {
     assertThatSerializedClass(ChangeMessage.Key.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("uuid", String.class)
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
index 65bdfbb..be7a5ee 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
@@ -38,13 +38,13 @@
   public void allValuesConvertedToProto() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
     changeMessage.setMessage("This is a change message.");
     changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(new Account.Id(10003));
+    changeMessage.setRealAuthor(Account.id(10003));
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -60,7 +60,7 @@
             .setPatchset(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(34))
-                    .setPatchSetId(13))
+                    .setId(13))
             .setTag("An arbitrary tag.")
             .setRealAuthor(Entities.Account_Id.newBuilder().setId(10003))
             .build();
@@ -71,10 +71,10 @@
   public void mainValuesConvertedToProto() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -89,7 +89,7 @@
             .setPatchset(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(34))
-                    .setPatchSetId(13))
+                    .setId(13))
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -99,10 +99,7 @@
   public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
-            null,
-            null);
+            ChangeMessage.key(Change.id(543), "change-message-21"), Account.id(63), null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -122,8 +119,7 @@
     // writtenOn may not be null according to the column definition but it's optional for the
     // protobuf definition. -> assume as optional and hence test null
     ChangeMessage changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -141,13 +137,13 @@
   public void allValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
     changeMessage.setMessage("This is a change message.");
     changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(new Account.Id(10003));
+    changeMessage.setRealAuthor(Account.id(10003));
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -158,10 +154,10 @@
   public void mainValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
         new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
-            new Account.Id(63),
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
             new Timestamp(9876543),
-            new PatchSet.Id(new Change.Id(34), 13));
+            PatchSet.id(Change.id(34), 13));
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -171,8 +167,7 @@
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java
index 61bf105..0393c15 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeProtoConverterTest.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -38,22 +38,22 @@
   public void allValuesConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch 74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
             new Timestamp(987654L));
     change.setLastUpdatedOn(new Timestamp(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
-        new PatchSet.Id(new Change.Id(14), 23), "subject XYZ", "original subject ABC");
+        PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(new Account.Id(100001));
+    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
-    change.setRevertOf(new Change.Id(180));
+    change.setRevertOf(Change.id(180));
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -67,8 +67,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch 74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch 74"))
             .setStatus(Change.STATUS_MERGED)
             .setCurrentPatchSetId(23)
             .setSubject("subject XYZ")
@@ -88,10 +88,10 @@
   public void mandatoryValuesConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
 
     Entities.Change proto = changeProtoConverter.toProto(change);
@@ -106,8 +106,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch-74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
             // Default values which can't be unset.
             .setCurrentPatchSetId(0)
             .setRowVersion(0)
@@ -124,13 +124,13 @@
   public void currentPatchSetIsAlwaysSetWhenConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
     // O as ID actually means that no current patch set is present.
-    change.setCurrentPatchSet(new PatchSet.Id(new Change.Id(14), 0), null, null);
+    change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -144,8 +144,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch-74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
             .setCurrentPatchSetId(0)
             // Default values which can't be unset.
             .setRowVersion(0)
@@ -162,12 +162,12 @@
   public void originalSubjectIsNotAutomaticallySetToSubjectWhenConvertedToProto() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
-    change.setCurrentPatchSet(new PatchSet.Id(new Change.Id(14), 23), "subject ABC", null);
+    change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -181,8 +181,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("refs/heads/branch-74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("refs/heads/branch-74"))
             .setCurrentPatchSetId(23)
             .setSubject("subject ABC")
             // Default values which can't be unset.
@@ -199,22 +199,22 @@
   public void allValuesConvertedToProtoAndBackAgain() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
     change.setLastUpdatedOn(new Timestamp(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
-        new PatchSet.Id(new Change.Id(14), 23), "subject XYZ", "original subject ABC");
+        PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(new Account.Id(100001));
+    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
-    change.setRevertOf(new Change.Id(180));
+    change.setRevertOf(Change.id(180));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
     assertEqualChange(convertedChange, change);
@@ -224,10 +224,10 @@
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     Change change =
         new Change(
-            new Change.Key("change 1"),
-            new Change.Id(14),
-            new Account.Id(35),
-            new Branch.NameKey(new Project.NameKey("project 67"), "branch-74"),
+            Change.key("change 1"),
+            Change.id(14),
+            Account.id(35),
+            BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             new Timestamp(987654L));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
@@ -269,8 +269,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("branch 74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("branch 74"))
             .build();
     Change change = changeProtoConverter.fromProto(proto);
 
@@ -289,8 +289,8 @@
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
             .setDest(
                 Entities.Branch_NameKey.newBuilder()
-                    .setProjectName(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranchName("branch 74"))
+                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
+                    .setBranch("branch 74"))
             .setStatus(Change.STATUS_MERGED)
             .setCurrentPatchSetId(23)
             .setSubject("subject XYZ")
@@ -323,7 +323,7 @@
                 .put("createdOn", Timestamp.class)
                 .put("lastUpdatedOn", Timestamp.class)
                 .put("owner", Account.Id.class)
-                .put("dest", Branch.NameKey.class)
+                .put("dest", BranchNameKey.class)
                 .put("status", char.class)
                 .put("currentPatchSetId", int.class)
                 .put("subject", String.class)
diff --git a/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
index 41e0f3f..a8dd0e2 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/LabelIdProtoConverterTest.java
@@ -30,7 +30,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    LabelId labelId = new LabelId("Label ID 42");
+    LabelId labelId = LabelId.create("Label ID 42");
 
     Entities.LabelId proto = labelIdProtoConverter.toProto(labelId);
 
@@ -40,7 +40,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    LabelId labelId = new LabelId("label-5");
+    LabelId labelId = LabelId.create("label-5");
 
     LabelId convertedLabelId =
         labelIdProtoConverter.fromProto(labelIdProtoConverter.toProto(labelId));
@@ -61,7 +61,8 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(LabelId.class).hasFields(ImmutableMap.of("id", String.class));
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(LabelId.class)
+        .hasAutoValueMethods(ImmutableMap.of("id", String.class));
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverterTest.java
new file mode 100644
index 0000000..e0dba83
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ObjectIdProtoConverterTest.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.protobuf.Parser;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ObjectIdProtoConverterTest {
+  private final ObjectIdProtoConverter objectIdProtoConverter = ObjectIdProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ObjectId objectId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    Entities.ObjectId proto = objectIdProtoConverter.toProto(objectId);
+
+    Entities.ObjectId expectedProto =
+        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ObjectId objectId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    ObjectId convertedObjectId =
+        objectIdProtoConverter.fromProto(objectIdProtoConverter.toProto(objectId));
+
+    assertThat(convertedObjectId).isEqualTo(objectId);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Entities.ObjectId proto =
+        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Entities.ObjectId> parser = objectIdProtoConverter.getParser();
+    Entities.ObjectId parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ObjectId.class)
+        .hasFields(
+            ImmutableMap.of(
+                "w1", int.class,
+                "w2", int.class,
+                "w3", int.class,
+                "w4", int.class,
+                "w5", int.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
index d1ed419..5e09e73 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -37,8 +37,8 @@
   @Test
   public void allValuesConvertedToProto() {
     PatchSetApproval.Key key =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(42), 14), new Account.Id(100013), new LabelId("label-8"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8"));
 
     Entities.PatchSetApproval_Key proto = protoConverter.toProto(key);
 
@@ -47,9 +47,9 @@
             .setPatchSetId(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setPatchSetId(14))
+                    .setId(14))
             .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setCategoryId(Entities.LabelId.newBuilder().setId("label-8"))
+            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -57,8 +57,8 @@
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
     PatchSetApproval.Key key =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(42), 14), new Account.Id(100013), new LabelId("label-8"));
+        PatchSetApproval.key(
+            PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8"));
 
     PatchSetApproval.Key convertedKey = protoConverter.fromProto(protoConverter.toProto(key));
 
@@ -72,9 +72,9 @@
             .setPatchSetId(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setPatchSetId(14))
+                    .setId(14))
             .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setCategoryId(Entities.LabelId.newBuilder().setId("label-8"))
+            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
             .build();
     byte[] bytes = proto.toByteArray();
 
@@ -86,13 +86,13 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
+  public void methodsExistAsExpected() {
     assertThatSerializedClass(PatchSetApproval.Key.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("patchSetId", PatchSet.Id.class)
                 .put("accountId", Account.Id.class)
-                .put("categoryId", LabelId.class)
+                .put("labelId", LabelId.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
index 80b2cc2..acb7d98 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetApprovalProtoConverterTest.java
@@ -26,10 +26,12 @@
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.inject.TypeLiteral;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Date;
+import java.util.Optional;
 import org.junit.Test;
 
 public class PatchSetApprovalProtoConverterTest {
@@ -39,16 +41,16 @@
   @Test
   public void allValuesConvertedToProto() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
-    patchSetApproval.setTag("tag-21");
-    patchSetApproval.setRealAccountId(new Account.Id(612));
-    patchSetApproval.setPostSubmit(true);
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .tag("tag-21")
+            .realAccountId(Account.id(612))
+            .postSubmit(true)
+            .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
 
@@ -59,9 +61,9 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .setValue(456)
             .setGranted(987654L)
             .setTag("tag-21")
@@ -74,13 +76,13 @@
   @Test
   public void mandatoryValuesConvertedToProto() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
 
@@ -91,9 +93,9 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .setValue(456)
             .setGranted(987654L)
             // This value can't be unset when our entity class is given.
@@ -105,16 +107,16 @@
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
-    patchSetApproval.setTag("tag-21");
-    patchSetApproval.setRealAccountId(new Account.Id(612));
-    patchSetApproval.setPostSubmit(true);
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .tag("tag-21")
+            .realAccountId(Account.id(612))
+            .postSubmit(true)
+            .build();
 
     PatchSetApproval convertedPatchSetApproval =
         protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
@@ -124,13 +126,13 @@
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     PatchSetApproval patchSetApproval =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(new Change.Id(42), 14),
-                new Account.Id(100013),
-                new LabelId("label-8")),
-            (short) 456,
-            new Date(987654L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .value(456)
+            .granted(new Date(987654L))
+            .build();
 
     PatchSetApproval convertedPatchSetApproval =
         protoConverter.fromProto(protoConverter.toProto(patchSetApproval));
@@ -148,19 +150,19 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .build();
     PatchSetApproval patchSetApproval = protoConverter.fromProto(proto);
 
-    assertThat(patchSetApproval.getPatchSetId()).isEqualTo(new PatchSet.Id(new Change.Id(42), 14));
-    assertThat(patchSetApproval.getAccountId()).isEqualTo(new Account.Id(100013));
-    assertThat(patchSetApproval.getLabelId()).isEqualTo(new LabelId("label-8"));
+    assertThat(patchSetApproval.patchSetId()).isEqualTo(PatchSet.id(Change.id(42), 14));
+    assertThat(patchSetApproval.accountId()).isEqualTo(Account.id(100013));
+    assertThat(patchSetApproval.labelId()).isEqualTo(LabelId.create("label-8"));
     // Default values for unset protobuf fields which can't be unset in the entity object.
-    assertThat(patchSetApproval.getValue()).isEqualTo(0);
-    assertThat(patchSetApproval.getGranted()).isEqualTo(new Timestamp(0));
-    assertThat(patchSetApproval.isPostSubmit()).isEqualTo(false);
+    assertThat(patchSetApproval.value()).isEqualTo(0);
+    assertThat(patchSetApproval.granted()).isEqualTo(new Timestamp(0));
+    assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
   }
 
   @Test
@@ -172,9 +174,9 @@
                     .setPatchSetId(
                         Entities.PatchSet_Id.newBuilder()
                             .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setPatchSetId(14))
+                            .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setCategoryId(Entities.LabelId.newBuilder().setId("label-8")))
+                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
             .setValue(456)
             .setGranted(987654L)
             .build();
@@ -190,14 +192,15 @@
   @Test
   public void fieldsExistAsExpected() {
     assertThatSerializedClass(PatchSetApproval.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
                 .put("value", short.class)
                 .put("granted", Timestamp.class)
-                .put("tag", String.class)
+                .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
+                .put("toBuilder", PatchSetApproval.Builder.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
index 1598ef2..76a290a 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetIdProtoConverterTest.java
@@ -33,21 +33,21 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    PatchSet.Id patchSetId = new PatchSet.Id(new Change.Id(103), 73);
+    PatchSet.Id patchSetId = PatchSet.id(Change.id(103), 73);
 
     Entities.PatchSet_Id proto = patchSetIdProtoConverter.toProto(patchSetId);
 
     Entities.PatchSet_Id expectedProto =
         Entities.PatchSet_Id.newBuilder()
             .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setPatchSetId(73)
+            .setId(73)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    PatchSet.Id patchSetId = new PatchSet.Id(new Change.Id(20), 13);
+    PatchSet.Id patchSetId = PatchSet.id(Change.id(20), 13);
 
     PatchSet.Id convertedPatchSetId =
         patchSetIdProtoConverter.fromProto(patchSetIdProtoConverter.toProto(patchSetId));
@@ -60,7 +60,7 @@
     Entities.PatchSet_Id proto =
         Entities.PatchSet_Id.newBuilder()
             .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setPatchSetId(73)
+            .setId(73)
             .build();
     byte[] bytes = proto.toByteArray();
 
@@ -72,12 +72,12 @@
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
-  public void fieldsExistAsExpected() {
+  public void methodsExistAsExpected() {
     assertThatSerializedClass(PatchSet.Id.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
-                .put("patchSetId", int.class)
+                .put("id", int.class)
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
index b8d2b1e..ffc6068 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/PatchSetProtoConverterTest.java
@@ -25,10 +25,12 @@
 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.RevId;
+import com.google.inject.TypeLiteral;
 import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class PatchSetProtoConverterTest {
@@ -36,13 +38,16 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
-    patchSet.setRevision(new RevId("aabbccddeeff"));
-    patchSet.setUploader(new Account.Id(452));
-    patchSet.setCreatedOn(new Timestamp(930349320L));
-    patchSet.setGroups(ImmutableList.of("group1, group2"));
-    patchSet.setPushCertificate("my push certificate");
-    patchSet.setDescription("This is a patch set description.");
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .groups(ImmutableList.of("group1", " group2"))
+            .pushCertificate("my push certificate")
+            .description("This is a patch set description.")
+            .build();
 
     Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
 
@@ -51,8 +56,9 @@
             .setId(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setPatchSetId(73))
-            .setRevision(Entities.RevId.newBuilder().setId("aabbccddeeff"))
+                    .setId(73))
+            .setCommitId(
+                Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
             .setCreatedOn(930349320L)
             .setGroups("group1, group2")
@@ -64,7 +70,13 @@
 
   @Test
   public void mandatoryValuesConvertedToProto() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .build();
 
     Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
 
@@ -73,20 +85,27 @@
             .setId(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setPatchSetId(73))
+                    .setId(73))
+            .setCommitId(
+                Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setCreatedOn(930349320L)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
-    patchSet.setRevision(new RevId("aabbccddeeff"));
-    patchSet.setUploader(new Account.Id(452));
-    patchSet.setCreatedOn(new Timestamp(930349320L));
-    patchSet.setGroups(ImmutableList.of("group1, group2"));
-    patchSet.setPushCertificate("my push certificate");
-    patchSet.setDescription("This is a patch set description.");
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .groups(ImmutableList.of("group1", " group2"))
+            .pushCertificate("my push certificate")
+            .description("This is a patch set description.")
+            .build();
 
     PatchSet convertedPatchSet =
         patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
@@ -95,7 +114,13 @@
 
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
-    PatchSet patchSet = new PatchSet(new PatchSet.Id(new Change.Id(103), 73));
+    PatchSet patchSet =
+        PatchSet.builder()
+            .id(PatchSet.id(Change.id(103), 73))
+            .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+            .uploader(Account.id(452))
+            .createdOn(new Timestamp(930349320L))
+            .build();
 
     PatchSet convertedPatchSet =
         patchSetProtoConverter.fromProto(patchSetProtoConverter.toProto(patchSet));
@@ -103,13 +128,34 @@
   }
 
   @Test
+  public void previouslyOptionalValuesMayBeMissingFromProto() {
+    Entities.PatchSet proto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .build();
+
+    PatchSet convertedPatchSet = patchSetProtoConverter.fromProto(proto);
+    Truth.assertThat(convertedPatchSet)
+        .isEqualTo(
+            PatchSet.builder()
+                .id(PatchSet.id(Change.id(103), 73))
+                .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
+                .uploader(Account.id(0))
+                .createdOn(new Timestamp(0))
+                .build());
+  }
+
+  @Test
   public void protoCanBeParsedFromBytes() throws Exception {
     Entities.PatchSet proto =
         Entities.PatchSet.newBuilder()
             .setId(
                 Entities.PatchSet_Id.newBuilder()
                     .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setPatchSetId(73))
+                    .setId(73))
             .build();
     byte[] bytes = proto.toByteArray();
 
@@ -123,15 +169,15 @@
   @Test
   public void fieldsExistAsExpected() {
     assertThatSerializedClass(PatchSet.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("id", PatchSet.Id.class)
-                .put("revision", RevId.class)
+                .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
                 .put("createdOn", Timestamp.class)
-                .put("groups", String.class)
-                .put("pushCertificate", String.class)
-                .put("description", String.class)
+                .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
+                .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("description", new TypeLiteral<Optional<String>>() {}.getType())
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java
index 2ad6107..05e2893 100644
--- a/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/reviewdb/converter/ProjectNameKeyProtoConverterTest.java
@@ -31,7 +31,7 @@
 
   @Test
   public void allValuesConvertedToProto() {
-    Project.NameKey nameKey = new Project.NameKey("project-72");
+    Project.NameKey nameKey = Project.nameKey("project-72");
 
     Entities.Project_NameKey proto = projectNameKeyProtoConverter.toProto(nameKey);
 
@@ -42,7 +42,7 @@
 
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
-    Project.NameKey nameKey = new Project.NameKey("project-52");
+    Project.NameKey nameKey = Project.nameKey("project-52");
 
     Project.NameKey convertedNameKey =
         projectNameKeyProtoConverter.fromProto(projectNameKeyProtoConverter.toProto(nameKey));
diff --git a/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java
deleted file mode 100644
index 2c354be..0000000
--- a/javatests/com/google/gerrit/reviewdb/converter/RevIdProtoConverterTest.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// 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.reviewdb.converter;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.protobuf.Parser;
-import org.junit.Test;
-
-public class RevIdProtoConverterTest {
-  private final RevIdProtoConverter revIdProtoConverter = RevIdProtoConverter.INSTANCE;
-
-  @Test
-  public void allValuesConvertedToProto() {
-    RevId revId = new RevId("9903402f303249e");
-
-    Entities.RevId proto = revIdProtoConverter.toProto(revId);
-
-    Entities.RevId expectedProto = Entities.RevId.newBuilder().setId("9903402f303249e").build();
-    assertThat(proto).isEqualTo(expectedProto);
-  }
-
-  @Test
-  public void allValuesConvertedToProtoAndBackAgain() {
-    RevId revId = new RevId("ff3934a320bb");
-
-    RevId convertedRevId = revIdProtoConverter.fromProto(revIdProtoConverter.toProto(revId));
-
-    assertThat(convertedRevId).isEqualTo(revId);
-  }
-
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.RevId proto = Entities.RevId.newBuilder().setId("9903402f303249e").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.RevId> parser = revIdProtoConverter.getParser();
-    Entities.RevId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
-  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
-  @Test
-  public void fieldsExistAsExpected() {
-    assertThatSerializedClass(RevId.class).hasFields(ImmutableMap.of("id", String.class));
-  }
-}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 321d1fd..5e3b35f 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -59,6 +59,7 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/schema/testing",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:assertable-executor",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
         "//java/org/eclipse/jgit:server",
diff --git a/javatests/com/google/gerrit/server/ChangeUtilTest.java b/javatests/com/google/gerrit/server/ChangeUtilTest.java
index 5cb474d..5f73d2c 100644
--- a/javatests/com/google/gerrit/server/ChangeUtilTest.java
+++ b/javatests/com/google/gerrit/server/ChangeUtilTest.java
@@ -16,11 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.regex.Pattern;
 import org.junit.Test;
 
-public class ChangeUtilTest extends GerritBaseTests {
+public class ChangeUtilTest {
   @Test
   public void changeMessageUuid() throws Exception {
     Pattern pat = Pattern.compile("^[0-9a-f]{8}_[0-9a-f]{8}$");
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 485de49..d6fb5d9 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeAccountCache;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -45,7 +44,7 @@
 import org.junit.runner.RunWith;
 
 @RunWith(ConfigSuite.class)
-public class IdentifiedUserTest extends GerritBaseTests {
+public class IdentifiedUserTest {
   @ConfigSuite.Parameter public Config config;
 
   private IdentifiedUser identifiedUser;
@@ -99,7 +98,7 @@
     Injector injector = Guice.createInjector(mod);
     injector.injectMembers(this);
 
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account account = new Account(Account.id(1), TimeUtil.nowTs());
     Account.Id ownerId = account.getId();
 
     identifiedUser = identifiedUserFactory.create(ownerId);
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 5f662e9..f03d60d 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -29,14 +29,13 @@
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Arrays;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 import org.junit.Test;
 
-public class AccountResolverTest extends GerritBaseTests {
+public class AccountResolverTest {
   private class TestSearcher extends StringSearcher {
     private final String pattern;
     private final boolean shortCircuit;
@@ -333,17 +332,17 @@
 
   private AccountState newAccount(int id) {
     return AccountState.forAccount(
-        new AllUsersName("All-Users"), new Account(new Account.Id(id), TimeUtil.nowTs()));
+        new AllUsersName("All-Users"), new Account(Account.id(id), TimeUtil.nowTs()));
   }
 
   private AccountState newInactiveAccount(int id) {
-    Account a = new Account(new Account.Id(id), TimeUtil.nowTs());
+    Account a = new Account(Account.id(id), TimeUtil.nowTs());
     a.setActive(false);
     return AccountState.forAccount(new AllUsersName("All-Users"), a);
   }
 
   private static ImmutableSet<Account.Id> ids(int... ids) {
-    return Arrays.stream(ids).mapToObj(Account.Id::new).collect(toImmutableSet());
+    return Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
   }
 
   private static Supplier<Predicate<AccountState>> allVisible() {
@@ -352,7 +351,7 @@
 
   private static Supplier<Predicate<AccountState>> only(int... ids) {
     ImmutableSet<Account.Id> idSet =
-        Arrays.stream(ids).mapToObj(Account.Id::new).collect(toImmutableSet());
+        Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
     return () -> a -> idSet.contains(a.getAccount().getId());
   }
 
diff --git a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
index 51a34f5..e85c575 100644
--- a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -17,13 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import org.junit.Test;
 
-public class AuthorizedKeysTest extends GerritBaseTests {
+public class AuthorizedKeysTest {
   private static final String KEY1 =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
           + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
@@ -55,7 +54,7 @@
           + "zRuEL5e/QOu9yGq9xkWApCmg6edpWAHG+Bx4AldU78MiZvzoB7gMMdxc9RmZ1gYj/DjxV"
           + "w== john.doe@example.com";
 
-  private final Account.Id accountId = new Account.Id(1);
+  private final Account.Id accountId = Account.id(1);
 
   @Test
   public void test() throws Exception {
@@ -151,7 +150,7 @@
 
   private static void assertParse(
       StringBuilder authorizedKeys, List<Optional<AccountSshKey>> expectedKeys) {
-    Account.Id accountId = new Account.Id(1);
+    Account.Id accountId = Account.id(1);
     List<Optional<AccountSshKey>> parsedKeys =
         AuthorizedKeys.parse(accountId, authorizedKeys.toString());
     assertThat(parsedKeys).containsExactlyElementsIn(expectedKeys);
@@ -171,7 +170,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey key = AccountSshKey.create(new Account.Id(1), keys.size() + 1, pub);
+    AccountSshKey key = AccountSshKey.create(Account.id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
     return key.sshPublicKey() + "\n";
   }
@@ -182,7 +181,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addInvalidKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey key = AccountSshKey.createInvalid(new Account.Id(1), keys.size() + 1, pub);
+    AccountSshKey key = AccountSshKey.createInvalid(Account.id(1), keys.size() + 1, pub);
     keys.add(Optional.of(key));
     return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX + key.sshPublicKey() + "\n";
   }
diff --git a/javatests/com/google/gerrit/server/account/DestinationListTest.java b/javatests/com/google/gerrit/server/account/DestinationListTest.java
index e51b041..4bef44a 100644
--- a/javatests/com/google/gerrit/server/account/DestinationListTest.java
+++ b/javatests/com/google/gerrit/server/account/DestinationListTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -27,7 +26,7 @@
 import java.util.Set;
 import org.junit.Test;
 
-public class DestinationListTest extends GerritBaseTests {
+public class DestinationListTest {
   public static final String R_FOO = "refs/heads/foo";
   public static final String R_BAR = "refs/heads/bar";
 
@@ -55,11 +54,11 @@
   public static final String LABEL = "label";
   public static final String LABEL2 = "another";
 
-  public static final Branch.NameKey B_FOO = dest(P_MY, R_FOO);
-  public static final Branch.NameKey B_BAR = dest(P_SLASH, R_BAR);
-  public static final Branch.NameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
+  public static final BranchNameKey B_FOO = dest(P_MY, R_FOO);
+  public static final BranchNameKey B_BAR = dest(P_SLASH, R_BAR);
+  public static final BranchNameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
 
-  public static final Set<Branch.NameKey> D_SIMPLE = new HashSet<>();
+  public static final Set<BranchNameKey> D_SIMPLE = new HashSet<>();
 
   static {
     D_SIMPLE.clear();
@@ -67,15 +66,15 @@
     D_SIMPLE.add(B_BAR);
   }
 
-  private static Branch.NameKey dest(String project, String ref) {
-    return new Branch.NameKey(new Project.NameKey(project), ref);
+  private static BranchNameKey dest(String project, String ref) {
+    return BranchNameKey.create(Project.nameKey(project), ref);
   }
 
   @Test
   public void testParseSimple() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -83,7 +82,7 @@
   public void testParseWHeader() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, HEADER + F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -91,7 +90,7 @@
   public void testParseWComments() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, C1 + F_SIMPLE + C2, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -99,7 +98,7 @@
   public void testParseFooComment() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, "#" + L_FOO + L_BAR, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).doesNotContain(B_FOO);
     assertThat(branches).contains(B_BAR);
   }
@@ -108,7 +107,7 @@
   public void testParsePaddedFronts() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_PAD_F, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -116,7 +115,7 @@
   public void testParsePaddedEnds() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_PAD_E, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
   }
 
@@ -124,7 +123,7 @@
   public void testParseComplex() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, L_COMPLEX, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).contains(B_COMPLEX);
   }
 
@@ -140,7 +139,7 @@
   public void testParse2Labels() throws Exception {
     DestinationList dl = new DestinationList();
     dl.parseLabel(LABEL, F_SIMPLE, null);
-    Set<Branch.NameKey> branches = dl.getDestinations(LABEL);
+    Set<BranchNameKey> branches = dl.getDestinations(LABEL);
     assertThat(branches).containsExactlyElementsIn(D_SIMPLE);
 
     dl.parseLabel(LABEL2, L_COMPLEX, null);
diff --git a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
index 70887e6..3b72b08 100644
--- a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
+++ b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
@@ -17,11 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
-public class GroupUUIDTest extends GerritBaseTests {
+public class GroupUUIDTest {
   @Test
   public void createdUuidsForSameInputShouldBeDifferent() {
     String groupName = "Users";
diff --git a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
index 9a0c9cb9..82943af 100644
--- a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
+++ b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.apache.commons.codec.DecoderException;
 import org.junit.Test;
 
-public class HashedPasswordTest extends GerritBaseTests {
+public class HashedPasswordTest {
 
   @Test
   public void encodeOneLine() throws Exception {
@@ -41,9 +41,9 @@
     assertThat(roundtrip.checkPassword("not the password")).isFalse();
   }
 
-  @Test(expected = DecoderException.class)
+  @Test
   public void invalidDecode() throws Exception {
-    HashedPassword.decode("invalid");
+    assertThrows(DecoderException.class, () -> HashedPassword.decode("invalid"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/QueryListTest.java b/javatests/com/google/gerrit/server/account/QueryListTest.java
index a0876e1..7d491c9 100644
--- a/javatests/com/google/gerrit/server/account/QueryListTest.java
+++ b/javatests/com/google/gerrit/server/account/QueryListTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.List;
 import org.junit.Test;
 
-public class QueryListTest extends GerritBaseTests {
+public class QueryListTest {
   public static final String Q_P = "project:foo";
   public static final String Q_B = "branch:bar";
   public static final String Q_COMPLEX = "branch:bar AND peers:'is:open\t'";
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 334c627..8bac910 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -37,14 +37,13 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
-public class UniversalGroupBackendTest extends GerritBaseTests {
-  private static final AccountGroup.UUID OTHER_UUID = new AccountGroup.UUID("other");
+public class UniversalGroupBackendTest {
+  private static final AccountGroup.UUID OTHER_UUID = AccountGroup.uuid("other");
 
   private UniversalGroupBackend backend;
   private IdentifiedUser user;
@@ -102,8 +101,8 @@
 
   @Test
   public void otherMemberships() {
-    final AccountGroup.UUID handled = new AccountGroup.UUID("handled");
-    final AccountGroup.UUID notHandled = new AccountGroup.UUID("not handled");
+    final AccountGroup.UUID handled = AccountGroup.uuid("handled");
+    final AccountGroup.UUID notHandled = AccountGroup.uuid("not handled");
     final IdentifiedUser member = createNiceMock(IdentifiedUser.class);
     final IdentifiedUser notMember = createNiceMock(IdentifiedUser.class);
 
diff --git a/javatests/com/google/gerrit/server/account/WatchConfigTest.java b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
index 2ac7be7..ef2d3be 100644
--- a/javatests/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -54,12 +55,12 @@
             + "  notify = [NEW_PATCHSETS]\n"
             + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
     Map<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
-        ProjectWatches.parse(new Account.Id(1000000), cfg, this);
+        ProjectWatches.parse(Account.id(1000000), cfg, this);
 
     assertThat(validationErrors).isEmpty();
 
-    Project.NameKey myProject = new Project.NameKey("myProject");
-    Project.NameKey otherProject = new Project.NameKey("otherProject");
+    Project.NameKey myProject = Project.nameKey("myProject");
+    Project.NameKey otherProject = Project.nameKey("otherProject");
     Map<ProjectWatchKey, Set<NotifyType>> expectedProjectWatches = new HashMap<>();
     expectedProjectWatches.put(
         ProjectWatchKey.create(myProject, null),
@@ -87,7 +88,7 @@
             + "[project \"otherProject\"]\n"
             + "  notify = [NEW_PATCHSETS]\n");
 
-    ProjectWatches.parse(new Account.Id(1000000), cfg, this);
+    ProjectWatches.parse(Account.id(1000000), cfg, this);
     assertThat(validationErrors).hasSize(1);
     assertThat(validationErrors.get(0).getMessage())
         .isEqualTo(
@@ -170,14 +171,14 @@
   private void assertParseNotifyValueFails(String notifyValue) {
     assertThat(validationErrors).isEmpty();
     parseNotifyValue(notifyValue);
-    assertThat(validationErrors)
-        .named("expected validation error for notifyValue: " + notifyValue)
+    assertWithMessage("expected validation error for notifyValue: " + notifyValue)
+        .that(validationErrors)
         .isNotEmpty();
     validationErrors.clear();
   }
 
   private NotifyValue parseNotifyValue(String notifyValue) {
-    return NotifyValue.parse(new Account.Id(1000000), "project", notifyValue, this);
+    return NotifyValue.parse(Account.id(1000000), "project", notifyValue, this);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
index d757f71..8487de4 100644
--- a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
@@ -25,13 +25,12 @@
 import com.google.gerrit.server.account.externalids.AllExternalIds.Serializer;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.TypeLiteral;
 import java.util.Arrays;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class AllExternalIdsTest extends GerritBaseTests {
+public class AllExternalIdsTest {
   @Test
   public void serializeEmptyExternalIds() throws Exception {
     assertRoundTrip(allExternalIds(), AllExternalIdsProto.getDefaultInstance());
@@ -39,8 +38,8 @@
 
   @Test
   public void serializeMultipleExternalIds() throws Exception {
-    Account.Id accountId1 = new Account.Id(1001);
-    Account.Id accountId2 = new Account.Id(1002);
+    Account.Id accountId1 = Account.id(1001);
+    Account.Id accountId2 = Account.id(1002);
     assertRoundTrip(
         allExternalIds(
             ExternalId.create("scheme1", "id1", accountId1),
@@ -62,7 +61,7 @@
   @Test
   public void serializeExternalIdWithEmail() throws Exception {
     assertRoundTrip(
-        allExternalIds(ExternalId.createEmail(new Account.Id(1001), "foo@example.com")),
+        allExternalIds(ExternalId.createEmail(Account.id(1001), "foo@example.com")),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
                 ExternalIdProto.newBuilder()
@@ -76,7 +75,7 @@
   public void serializeExternalIdWithPassword() throws Exception {
     assertRoundTrip(
         allExternalIds(
-            ExternalId.create("scheme", "id", new Account.Id(1001), null, "hashed password")),
+            ExternalId.create("scheme", "id", Account.id(1001), null, "hashed password")),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
                 ExternalIdProto.newBuilder()
@@ -91,7 +90,7 @@
     assertRoundTrip(
         allExternalIds(
             ExternalId.create(
-                ExternalId.create("scheme", "id", new Account.Id(1001)),
+                ExternalId.create("scheme", "id", Account.id(1001)),
                 ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index 6a42577..d19073d 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.cache;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.function.Supplier;
 import org.junit.Test;
 
-public class PerThreadCacheTest extends GerritBaseTests {
+public class PerThreadCacheTest {
   @Test
   public void key_respectsClass() {
     assertThat(PerThreadCache.Key.create(String.class))
@@ -75,9 +75,9 @@
   @Test
   public void doubleInstantiationFails() {
     try (PerThreadCache ignored = PerThreadCache.create()) {
-      exception.expect(IllegalStateException.class);
-      exception.expectMessage("called create() twice on the same request");
-      PerThreadCache.create();
+      IllegalStateException thrown =
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+      assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 147aeeb..69c2799 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.cache.h2;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -66,22 +67,22 @@
                   return "bar";
                 }))
         .isEqualTo("bar");
-    assertThat(called.get()).named("Callable was called").isTrue();
-    assertThat(impl.getIfPresent("foo")).named("in-memory value").isEqualTo("bar");
+    assertWithMessage("Callable was called").that(called.get()).isTrue();
+    assertWithMessage("in-memory value").that(impl.getIfPresent("foo")).isEqualTo("bar");
     mem.invalidate("foo");
-    assertThat(impl.getIfPresent("foo")).named("persistent value").isEqualTo("bar");
+    assertWithMessage("persistent value").that(impl.getIfPresent("foo")).isEqualTo("bar");
 
     called.set(false);
-    assertThat(
+    assertWithMessage("cached value")
+        .that(
             impl.get(
                 "foo",
                 () -> {
                   called.set(true);
                   return "baz";
                 }))
-        .named("cached value")
         .isEqualTo("bar");
-    assertThat(called.get()).named("Callable was called").isFalse();
+    assertWithMessage("Callable was called").that(called.get()).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
index a0d5ea6..271c27d 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -7,7 +7,6 @@
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//java/com/google/gwtorm",
         "//lib:guava",
         "//lib:junit",
         "//lib:protobuf",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
index c634a78..7504850 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
@@ -18,11 +18,10 @@
 import static com.google.common.truth.Truth.assert_;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.TextFormat;
 import org.junit.Test;
 
-public class BooleanCacheSerializerTest extends GerritBaseTests {
+public class BooleanCacheSerializerTest {
   @Test
   public void serialize() throws Exception {
     assertThat(BooleanCacheSerializer.INSTANCE.serialize(true))
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.java
new file mode 100644
index 0000000..ac334d2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CacheSerializerTest.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.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import org.junit.Test;
+
+public class CacheSerializerTest {
+  @AutoValue
+  abstract static class MyAutoValue {
+    static MyAutoValue create(int val) {
+      return new AutoValue_CacheSerializerTest_MyAutoValue(val);
+    }
+
+    abstract int val();
+  }
+
+  private static final CacheSerializer<MyAutoValue> SERIALIZER =
+      CacheSerializer.convert(
+          IntegerCacheSerializer.INSTANCE, Converter.from(MyAutoValue::val, MyAutoValue::create));
+
+  @Test
+  public void serialize() throws Exception {
+    MyAutoValue v = MyAutoValue.create(1234);
+    byte[] serialized = SERIALIZER.serialize(v);
+    assertThat(serialized).isEqualTo(new byte[] {-46, 9});
+    assertThat(SERIALIZER.deserialize(serialized).val()).isEqualTo(1234);
+  }
+
+  @Test
+  public void deserializeNullFails() throws Exception {
+    try {
+      SERIALIZER.deserialize(null);
+      assert_().fail("expected RuntimeException");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
index c6efc21..0b80fc7 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
@@ -18,10 +18,9 @@
 import static com.google.common.truth.Truth.assert_;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class EnumCacheSerializerTest extends GerritBaseTests {
+public class EnumCacheSerializerTest {
   @Test
   public void serialize() throws Exception {
     assertRoundTrip(MyEnum.FOO);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
deleted file mode 100644
index 56dd6ad..0000000
--- a/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// 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.serialize;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import com.google.gerrit.testing.GerritBaseTests;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.Key;
-import org.junit.Test;
-
-public class IntKeyCacheSerializerTest extends GerritBaseTests {
-
-  private static class MyIntKey extends IntKey<Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    private int val;
-
-    MyIntKey(int val) {
-      this.val = val;
-    }
-
-    @Override
-    public int get() {
-      return val;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      this.val = newValue;
-    }
-  }
-
-  private static final IntKeyCacheSerializer<MyIntKey> SERIALIZER =
-      new IntKeyCacheSerializer<>(MyIntKey::new);
-
-  @Test
-  public void serialize() throws Exception {
-    MyIntKey k = new MyIntKey(1234);
-    byte[] serialized = SERIALIZER.serialize(k);
-    assertThat(serialized).isEqualTo(new byte[] {-46, 9});
-    assertThat(SERIALIZER.deserialize(serialized).get()).isEqualTo(1234);
-  }
-
-  @Test
-  public void deserializeNullFails() throws Exception {
-    try {
-      SERIALIZER.deserialize(null);
-      assert_().fail("expected RuntimeException");
-    } catch (RuntimeException e) {
-      // Expected.
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
index 1d54010..dfd23e6 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.server.cache.serialize;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth.assert_;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Bytes;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.TextFormat;
 import org.junit.Test;
 
-public class IntegerCacheSerializerTest extends GerritBaseTests {
+public class IntegerCacheSerializerTest {
   @Test
   public void serialize() throws Exception {
     for (int i :
@@ -49,8 +48,8 @@
   private static void assertRoundTrip(int i) throws Exception {
     byte[] serialized = IntegerCacheSerializer.INSTANCE.serialize(i);
     int result = IntegerCacheSerializer.INSTANCE.deserialize(serialized);
-    assertThat(result)
-        .named("round-trip of %s via \"%s\"", i, TextFormat.escapeBytes(serialized))
+    assertWithMessage("round-trip of %s via \"%s\"", i, TextFormat.escapeBytes(serialized))
+        .that(result)
         .isEqualTo(i);
   }
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
index 9fcb8a4..6596730 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
@@ -17,11 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.Serializable;
 import org.junit.Test;
 
-public class JavaCacheSerializerTest extends GerritBaseTests {
+public class JavaCacheSerializerTest {
   @Test
   public void builtInTypes() throws Exception {
     assertRoundTrip("foo");
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
index 257be54..c56f8f8 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdCacheSerializerTest.java
@@ -18,11 +18,10 @@
 import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteArray;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ObjectIdCacheSerializerTest extends GerritBaseTests {
+public class ObjectIdCacheSerializerTest {
   @Test
   public void serialize() {
     ObjectId id = ObjectId.fromString("aabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
index c5ea2ea..c8c80b4 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ObjectIdConverterTest.java
@@ -18,12 +18,11 @@
 import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ObjectIdConverterTest extends GerritBaseTests {
+public class ObjectIdConverterTest {
   @Test
   public void objectIdFromByteString() {
     ObjectIdConverter idConverter = ObjectIdConverter.create();
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
index 845da9b..04d2f73 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ProtobufSerializerTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.proto.testing.Test.SerializableProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class ProtobufSerializerTest extends GerritBaseTests {
+public class ProtobufSerializerTest {
   @Test
   public void requiredAndOptionalTypes() {
     assertRoundTrip(SerializableProto.newBuilder().setId(123));
diff --git a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
index ff0cf9a..fa3b7d7 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
@@ -17,12 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.nio.charset.CharacterCodingException;
 import java.nio.charset.StandardCharsets;
 import org.junit.Test;
 
-public class StringCacheSerializerTest extends GerritBaseTests {
+public class StringCacheSerializerTest {
   @Test
   public void serialize() {
     assertThat(StringCacheSerializer.INSTANCE.serialize("")).isEmpty();
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index fffb1da..20813f6 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -24,11 +24,10 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.change.ChangeKindCacheImpl.Key;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ChangeKindCacheImplTest extends GerritBaseTests {
+public class ChangeKindCacheImplTest {
   @Test
   public void keySerializer() throws Exception {
     ChangeKindCacheImpl.Key key =
diff --git a/javatests/com/google/gerrit/server/change/HashtagsTest.java b/javatests/com/google/gerrit/server/change/HashtagsTest.java
index 49d2952..780ac71 100644
--- a/javatests/com/google/gerrit/server/change/HashtagsTest.java
+++ b/javatests/com/google/gerrit/server/change/HashtagsTest.java
@@ -17,10 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class HashtagsTest extends GerritBaseTests {
+public class HashtagsTest {
   @Test
   public void emptyCommitMessage() throws Exception {
     assertThat(HashtagsUtil.extractTags("")).isEmpty();
diff --git a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
index 0cfe483..13b58e6 100644
--- a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -27,7 +26,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class IncludedInResolverTest extends GerritBaseTests {
+public class IncludedInResolverTest {
   // Branch names
   private static final String BRANCH_MASTER = "master";
   private static final String BRANCH_1_0 = "rel-1.0";
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 6e02d61..85559cb 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -57,7 +56,7 @@
 import org.junit.Test;
 
 /** Unit tests for {@link LabelNormalizer}. */
-public class LabelNormalizerTest extends GerritBaseTests {
+public class LabelNormalizerTest {
   @Inject private AccountManager accountManager;
   @Inject private AllProjectsName allProjects;
   @Inject private GitRepositoryManager repoManager;
@@ -115,7 +114,7 @@
     input.newBranch = true;
     input.subject = "Test change";
     ChangeInfo info = gApi.changes().create(input).get();
-    notes = changeNotesFactory.createChecked(allProjects, new Change.Id(info._number));
+    notes = changeNotesFactory.createChecked(allProjects, Change.id(info._number));
     change = notes.getChange();
   }
 
@@ -187,16 +186,15 @@
   }
 
   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());
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(change.currentPatchSetId(), accountId, LabelId.create(label)))
+        .value(value)
+        .granted(TimeUtil.nowTs())
+        .build();
   }
 
   private PatchSetApproval copy(PatchSetApproval src, int newValue) {
-    PatchSetApproval result = new PatchSetApproval(src.getKey().getParentKey(), src);
-    result.setValue((short) newValue);
-    return result;
+    return src.toBuilder().value(newValue).build();
   }
 
   private static List<PatchSetApproval> list(PatchSetApproval... psas) {
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
index 46ddbc2..19c8998 100644
--- a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -23,11 +23,10 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class MergeabilityCacheImplTest extends GerritBaseTests {
+public class MergeabilityCacheImplTest {
   @Test
   public void keySerializer() throws Exception {
     MergeabilityCacheImpl.EntryKey key =
diff --git a/javatests/com/google/gerrit/server/change/WalkSorterTest.java b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
index 189dfbc..4a42140 100644
--- a/javatests/com/google/gerrit/server/change/WalkSorterTest.java
+++ b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
@@ -23,10 +23,8 @@
 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.RevId;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testing.TestChanges;
@@ -39,13 +37,13 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class WalkSorterTest extends GerritBaseTests {
+public class WalkSorterTest {
   private Account.Id userId;
   private InMemoryRepositoryManager repoManager;
 
   @Before
   public void setUp() {
-    userId = new Account.Id(1);
+    userId = Account.id(1);
     repoManager = new InMemoryRepositoryManager();
   }
 
@@ -280,7 +278,7 @@
 
     // 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)));
+        ImmutableSet.of(PatchSet.id(cd1.getId(), 1), PatchSet.id(cd2.getId(), 1)));
     assertSorted(
         sorter, changes, ImmutableList.of(patchSetData(cd2, 1, c2_1), patchSetData(cd1, 1, c1_1)));
   }
@@ -297,8 +295,7 @@
 
     List<ChangeData> changes = ImmutableList.of(cd1, cd2);
     WalkSorter sorter =
-        new WalkSorter(repoManager)
-            .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
+        new WalkSorter(repoManager).includePatchSets(ImmutableSet.of(cd1.currentPatchSet().id()));
 
     assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd1, c1)));
   }
@@ -335,17 +332,15 @@
   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);
+    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1, id);
     cd.setChange(c);
-    cd.currentPatchSet().setRevision(new RevId(id.name()));
     cd.setPatchSets(ImmutableList.of(cd.currentPatchSet()));
     return cd;
   }
 
   private PatchSet addPatchSet(ChangeData cd, ObjectId id) throws Exception {
     TestChanges.incrementPatchSet(cd.change());
-    PatchSet ps = new PatchSet(cd.change().currentPatchSetId());
-    ps.setRevision(new RevId(id.name()));
+    PatchSet ps = TestChanges.newPatchSet(cd.change().currentPatchSetId(), id.name(), userId);
     List<PatchSet> patchSets = new ArrayList<>(cd.patchSets());
     patchSets.add(ps);
     cd.setPatchSets(patchSets);
@@ -353,7 +348,7 @@
   }
 
   private TestRepository<Repo> newRepo(String name) throws Exception {
-    return new TestRepository<>(repoManager.createRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.createRepository(Project.nameKey(name)));
   }
 
   private static PatchSetData patchSetData(ChangeData cd, RevCommit commit) throws Exception {
@@ -362,7 +357,7 @@
 
   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);
+    return PatchSetData.create(cd, cd.patchSet(PatchSet.id(cd.getId(), psId)), commit);
   }
 
   private static void assertSorted(
diff --git a/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java b/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java
new file mode 100644
index 0000000..979967d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/AllProjectsNameTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Test;
+
+public class AllProjectsNameTest {
+  @Test
+  public void equalToProjectNameKey() {
+    String name = "a-project";
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    Project.NameKey projectName = Project.nameKey(name);
+    assertThat(allProjectsName.get()).isEqualTo(projectName.get());
+    assertThat(allProjectsName).isEqualTo(projectName);
+  }
+
+  @Test
+  public void equalToAllUsersName() {
+    String name = "a-project";
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    AllUsersName allUsersName = new AllUsersName(name);
+    assertThat(allProjectsName.get()).isEqualTo(allUsersName.get());
+    assertThat(allProjectsName).isEqualTo(allUsersName);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/AllUsersNameTest.java b/javatests/com/google/gerrit/server/config/AllUsersNameTest.java
new file mode 100644
index 0000000..4edc923
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/AllUsersNameTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Test;
+
+public class AllUsersNameTest {
+  @Test
+  public void equalToProjectNameKey() {
+    String name = "a-project";
+    AllUsersName allUsersName = new AllUsersName(name);
+    Project.NameKey projectName = Project.nameKey(name);
+    assertThat(allUsersName.get()).isEqualTo(projectName.get());
+    assertThat(allUsersName).isEqualTo(projectName);
+  }
+
+  @Test
+  public void equalToAllProjectsName() {
+    String name = "a-project";
+    AllUsersName allUsersName = new AllUsersName(name);
+    AllProjectsName allProjectsName = new AllProjectsName(name);
+    assertThat(allUsersName.get()).isEqualTo(allProjectsName.get());
+    assertThat(allUsersName).isEqualTo(allProjectsName);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
index 75fb94e..865bda6 100644
--- a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
+++ b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -22,14 +23,13 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.gerrit.extensions.client.Theme;
-import com.google.gerrit.testing.GerritBaseTests;
 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 extends GerritBaseTests {
+public class ConfigUtilTest {
   private static final String SECT = "foo";
   private static final String SUB = "bar";
 
@@ -91,17 +91,17 @@
     Config cfg = new Config();
     ConfigUtil.storeSection(cfg, SECT, SUB, in, d);
 
-    assertThat(cfg.getString(SECT, SUB, "CONSTANT")).isNull();
-    assertThat(cfg.getString(SECT, SUB, "missing")).isNull();
-    assertThat(cfg.getBoolean(SECT, SUB, "b", false)).isEqualTo(in.b);
-    assertThat(cfg.getBoolean(SECT, SUB, "bb", false)).isEqualTo(in.bb);
-    assertThat(cfg.getInt(SECT, SUB, "i", 0)).isEqualTo(0);
-    assertThat(cfg.getInt(SECT, SUB, "ii", 0)).isEqualTo(in.ii);
-    assertThat(cfg.getLong(SECT, SUB, "l", 0L)).isEqualTo(0L);
-    assertThat(cfg.getLong(SECT, SUB, "ll", 0L)).isEqualTo(in.ll);
-    assertThat(cfg.getString(SECT, SUB, "s")).isEqualTo(in.s);
-    assertThat(cfg.getString(SECT, SUB, "sd")).isNull();
-    assertThat(cfg.getString(SECT, SUB, "nd")).isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "CONSTANT").isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "missing").isNull();
+    assertThat(cfg).booleanValue(SECT, SUB, "b", false).isEqualTo(in.b);
+    assertThat(cfg).booleanValue(SECT, SUB, "bb", false).isEqualTo(in.bb);
+    assertThat(cfg).intValue(SECT, SUB, "i", 0).isEqualTo(0);
+    assertThat(cfg).intValue(SECT, SUB, "ii", 0).isEqualTo(in.ii);
+    assertThat(cfg).longValue(SECT, SUB, "l", 0L).isEqualTo(0L);
+    assertThat(cfg).longValue(SECT, SUB, "ll", 0L).isEqualTo(in.ll);
+    assertThat(cfg).stringValue(SECT, SUB, "s").isEqualTo(in.s);
+    assertThat(cfg).stringValue(SECT, SUB, "sd").isNull();
+    assertThat(cfg).stringValue(SECT, SUB, "nd").isNull();
 
     SectionInfo out = new SectionInfo();
     ConfigUtil.loadSection(cfg, SECT, SUB, out, d, null);
diff --git a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
index bf7e4fd..cb6de34 100644
--- a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertWithMessage;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class GitwebConfigTest extends GerritBaseTests {
+public class GitwebConfigTest {
   private static final String VALID_CHARACTERS = "*()";
   private static final String SOME_INVALID_CHARACTERS = "09AZaz$-_.+!',";
 
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
index 30fabdc..de36ccc 100644
--- a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.restapi.config.ListCapabilities;
 import com.google.gerrit.server.restapi.config.ListCapabilities.CapabilityInfo;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
@@ -36,7 +35,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ListCapabilitiesTest extends GerritBaseTests {
+public class ListCapabilitiesTest {
   private Injector injector;
 
   @Before
diff --git a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
index 2a473f4..895cc7e 100644
--- a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -28,7 +27,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class RepositoryConfigTest extends GerritBaseTests {
+public class RepositoryConfigTest {
 
   private Config cfg;
   private RepositoryConfig repoCfg;
@@ -42,35 +41,35 @@
   @Test
   public void defaultSubmitTypeWhenNotConfigured() {
     // Check expected value explicitly rather than depending on constant.
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.INHERIT);
   }
 
   @Test
   public void defaultSubmitTypeForStarFilter() {
     configureDefaultSubmitType("*", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
     configureDefaultSubmitType("*", SubmitType.FAST_FORWARD_ONLY);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.FAST_FORWARD_ONLY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_IF_NECESSARY);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
 
     configureDefaultSubmitType("*", SubmitType.REBASE_ALWAYS);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_ALWAYS);
   }
 
   @Test
   public void defaultSubmitTypeForSpecificFilter() {
     configureDefaultSubmitType("someProject", SubmitType.CHERRY_PICK);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someOtherProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someOtherProject")))
         .isEqualTo(RepositoryConfig.DEFAULT_SUBMIT_TYPE);
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
   }
 
@@ -80,13 +79,13 @@
     configureDefaultSubmitType("somePath/*", SubmitType.CHERRY_PICK);
     configureDefaultSubmitType("*", SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("someProject")))
         .isEqualTo(SubmitType.MERGE_ALWAYS);
 
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("somePath/someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
-    assertThat(repoCfg.getDefaultSubmitType(new Project.NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(Project.nameKey("somePath/somePath/someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
   }
 
@@ -100,14 +99,14 @@
 
   @Test
   public void ownerGroupsWhenNotConfigured() {
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject"))).isEmpty();
   }
 
   @Test
   public void ownerGroupsForStarFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("*", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -115,8 +114,8 @@
   public void ownerGroupsForSpecificFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("someProject", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someOtherProject"))).isEmpty();
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someOtherProject"))).isEmpty();
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
@@ -130,13 +129,13 @@
     configureOwnerGroups("somePath/*", ownerGroups2);
     configureOwnerGroups("somePath/somePath/*", ownerGroups3);
 
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups1);
 
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups2);
 
-    assertThat(repoCfg.getOwnerGroups(new Project.NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(Project.nameKey("somePath/somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups3);
   }
 
@@ -150,24 +149,22 @@
 
   @Test
   public void basePathWhenNotConfigured() {
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject"))).isNull();
   }
 
   @Test
   public void basePathForStarFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("*", basePath);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
-        .isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
   public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someOtherProject"))).isNull();
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
-        .isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
@@ -182,14 +179,12 @@
     configureBasePath("project/*", basePath3);
     configureBasePath("*", basePath4);
 
-    assertThat(repoCfg.getBasePath(new Project.NameKey("project1")).toString())
-        .isEqualTo(basePath1);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("project/project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(Project.nameKey("project1")).toString()).isEqualTo(basePath1);
+    assertThat(repoCfg.getBasePath(Project.nameKey("project/project/someProject")).toString())
         .isEqualTo(basePath2);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("project/someProject")).toString())
+    assertThat(repoCfg.getBasePath(Project.nameKey("project/someProject")).toString())
         .isEqualTo(basePath3);
-    assertThat(repoCfg.getBasePath(new Project.NameKey("someProject")).toString())
-        .isEqualTo(basePath4);
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath4);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
index 6926052..55f0374 100644
--- a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -22,7 +22,6 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
@@ -32,7 +31,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class ScheduleConfigTest extends GerritBaseTests {
+public class ScheduleConfigTest {
 
   // Friday June 13, 2014 10:00 UTC
   private static final ZonedDateTime NOW =
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
index b4cde14..1e5f41d 100644
--- a/javatests/com/google/gerrit/server/config/SitePathsTest.java
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.server.ioutil.HostPlatform;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.NotDirectoryException;
@@ -26,7 +26,7 @@
 import java.nio.file.Paths;
 import org.junit.Test;
 
-public class SitePathsTest extends GerritBaseTests {
+public class SitePathsTest {
   @Test
   public void create_NotExisting() throws IOException {
     final Path root = random();
@@ -72,8 +72,8 @@
     final Path root = random();
     try {
       Files.createFile(root);
-      exception.expect(NotDirectoryException.class);
-      new SitePaths(root);
+      assertThrows(NotDirectoryException.class, () -> new SitePaths(root));
+
     } finally {
       Files.delete(root);
     }
diff --git a/javatests/com/google/gerrit/server/edit/ChangeEditTest.java b/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
index 4c0b5a1..c7ed865 100644
--- a/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
+++ b/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
@@ -20,15 +20,14 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class ChangeEditTest extends GerritBaseTests {
+public class ChangeEditTest {
   @Test
   public void changeEditRef() throws Exception {
-    Account.Id accountId = new Account.Id(1000042);
-    Change.Id changeId = new Change.Id(56414);
-    PatchSet.Id psId = new PatchSet.Id(changeId, 50);
+    Account.Id accountId = Account.id(1000042);
+    Change.Id changeId = Change.id(56414);
+    PatchSet.Id psId = PatchSet.id(changeId, 50);
     String refName = RefNames.refsEdit(accountId, changeId, psId);
     assertEquals("refs/users/42/1000042/edit-56414/50", refName);
   }
diff --git a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
index 265b24e..b23c47a 100644
--- a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -30,26 +30,34 @@
 
   public static ChangeFileContentModificationSubject assertThat(
       ChangeFileContentModification modification) {
-    return assertAbout(ChangeFileContentModificationSubject::new).that(modification);
+    return assertAbout(modifications()).that(modification);
   }
 
+  public static Factory<ChangeFileContentModificationSubject, ChangeFileContentModification>
+      modifications() {
+    return ChangeFileContentModificationSubject::new;
+  }
+
+  private final ChangeFileContentModification modification;
+
   private ChangeFileContentModificationSubject(
       FailureMetadata failureMetadata, ChangeFileContentModification modification) {
     super(failureMetadata, modification);
+    this.modification = modification;
   }
 
   public StringSubject filePath() {
     isNotNull();
-    return check("filePath()").that(actual().getFilePath());
+    return check("getFilePath()").that(modification.getFilePath());
   }
 
   public StringSubject newContent() throws IOException {
     isNotNull();
-    RawInput newContent = actual().getNewContent();
-    check("newContent()").that(newContent).isNotNull();
+    RawInput newContent = modification.getNewContent();
+    check("getNewContent()").that(newContent).isNotNull();
     String contentString =
         CharStreams.toString(
             new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
-    return check("newContent()").that(contentString);
+    return check("getNewContent()").that(contentString);
   }
 }
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
index bd9d4df..72759cd 100644
--- a/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
@@ -33,19 +33,21 @@
 
   public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
       List<TreeModification> treeModifications) {
-    return assertAbout(ListSubject.elements())
-        .thatCustom(treeModifications, treeModifications())
-        .named("treeModifications");
+    return ListSubject.assertThat(treeModifications, treeModifications());
   }
 
+  private final TreeModification treeModification;
+
   private TreeModificationSubject(
       FailureMetadata failureMetadata, TreeModification treeModification) {
     super(failureMetadata, treeModification);
+    this.treeModification = treeModification;
   }
 
   public ChangeFileContentModificationSubject asChangeFileContentModification() {
     isInstanceOf(ChangeFileContentModification.class);
-    return ChangeFileContentModificationSubject.assertThat(
-        (ChangeFileContentModification) actual());
+    return check("asChangeFileContentModification()")
+        .about(ChangeFileContentModificationSubject.modifications())
+        .that((ChangeFileContentModification) treeModification);
   }
 }
diff --git a/javatests/com/google/gerrit/server/events/BUILD b/javatests/com/google/gerrit/server/events/BUILD
new file mode 100644
index 0000000..f27e4a6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/events/BUILD
@@ -0,0 +1,15 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "events_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index eac8d0d..aacee8a 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -18,38 +18,32 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
+import java.sql.Timestamp;
 import org.junit.Test;
 
-public class EventDeserializerTest extends GerritBaseTests {
+public class EventDeserializerTest {
+  private final Gson gson = new EventGsonProvider().get();
 
   @Test
   public void refUpdatedEvent() {
-    RefUpdatedEvent refUpdatedEvent = new RefUpdatedEvent();
-
+    RefUpdatedEvent orig = new RefUpdatedEvent();
     RefUpdateAttribute refUpdatedAttribute = new RefUpdateAttribute();
     refUpdatedAttribute.refName = "refs/heads/master";
-    refUpdatedEvent.refUpdate = createSupplier(refUpdatedAttribute);
+    orig.refUpdate = createSupplier(refUpdatedAttribute);
 
     AccountAttribute accountAttribute = new AccountAttribute();
     accountAttribute.email = "some.user@domain.com";
-    refUpdatedEvent.submitter = createSupplier(accountAttribute);
+    orig.submitter = createSupplier(accountAttribute);
 
-    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();
-
-    RefUpdatedEvent e = (RefUpdatedEvent) gsonDeserializer.fromJson(serializedEvent, Event.class);
+    RefUpdatedEvent e = roundTrip(orig);
 
     assertThat(e).isNotNull();
     assertThat(e.refUpdate).isInstanceOf(Supplier.class);
@@ -58,7 +52,271 @@
     assertThat(e.submitter.get().email).isEqualTo(accountAttribute.email);
   }
 
+  @Test
+  public void patchSetCreatedEvent() {
+    Change change = newChange();
+    PatchSetCreatedEvent orig = new PatchSetCreatedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.uploader = newAccount("uploader");
+
+    PatchSetCreatedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.uploader, orig.uploader);
+  }
+
+  @Test
+  public void assigneeChangedEvent() {
+    Change change = newChange();
+    AssigneeChangedEvent orig = new AssigneeChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.changer = newAccount("changer");
+    orig.oldAssignee = newAccount("oldAssignee");
+
+    AssigneeChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.changer, orig.changer);
+    assertSameAccount(e.oldAssignee, orig.oldAssignee);
+  }
+
+  @Test
+  public void changeDeletedEvent() {
+    Change change = newChange();
+    ChangeDeletedEvent orig = new ChangeDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.deleter = newAccount("deleter");
+
+    ChangeDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.deleter, orig.deleter);
+  }
+
+  @Test
+  public void hashtagsChangedEvent() {
+    Change change = newChange();
+    HashtagsChangedEvent orig = new HashtagsChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.editor = newAccount("editor");
+    orig.added = new String[] {"added"};
+    orig.removed = new String[] {"removed"};
+    orig.hashtags = new String[] {"hashtags"};
+
+    HashtagsChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.editor, orig.editor);
+    assertThat(e.added).isEqualTo(orig.added);
+    assertThat(e.removed).isEqualTo(orig.removed);
+    assertThat(e.hashtags).isEqualTo(orig.hashtags);
+  }
+
+  @Test
+  public void changeAbandonedEvent() {
+    Change change = newChange();
+    ChangeAbandonedEvent orig = new ChangeAbandonedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.abandoner = newAccount("abandoner");
+    orig.reason = "some reason";
+
+    ChangeAbandonedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.abandoner, orig.abandoner);
+    assertThat(e.reason).isEqualTo(orig.reason);
+  }
+
+  @Test
+  public void changeMergedEvent() {
+    Change change = newChange();
+    ChangeMergedEvent orig = new ChangeMergedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ChangeMergedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void changeRestoredEvent() {
+    Change change = newChange();
+    ChangeRestoredEvent orig = new ChangeRestoredEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ChangeRestoredEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void commentAddedEvent() {
+    Change change = newChange();
+    CommentAddedEvent orig = new CommentAddedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    CommentAddedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void privateStateChangedEvent() {
+    Change change = newChange();
+    PrivateStateChangedEvent orig = new PrivateStateChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    PrivateStateChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void reviewerAddedEvent() {
+    Change change = newChange();
+    ReviewerAddedEvent orig = new ReviewerAddedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ReviewerAddedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void reviewerDeletedEvent() {
+    Change change = newChange();
+    ReviewerDeletedEvent orig = new ReviewerDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    ReviewerDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void voteDeletedEvent() {
+    Change change = newChange();
+    VoteDeletedEvent orig = new VoteDeletedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    VoteDeletedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void workinProgressStateChangedEvent() {
+    Change change = newChange();
+    WorkInProgressStateChangedEvent orig = new WorkInProgressStateChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    WorkInProgressStateChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
+  @Test
+  public void topicChangedEvent() {
+    Change change = newChange();
+    TopicChangedEvent orig = new TopicChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+
+    TopicChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+  }
+
   private <T> Supplier<T> createSupplier(T value) {
     return Suppliers.memoize(() -> value);
   }
+
+  private Change newChange() {
+    Change change =
+        new Change(
+            Change.key("Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+            Change.id(1000),
+            Account.id(1000),
+            BranchNameKey.create(Project.nameKey("myproject"), "mybranch"),
+            new Timestamp(System.currentTimeMillis()));
+    return change;
+  }
+
+  private Supplier<AccountAttribute> newAccount(String name) {
+    AccountAttribute account = new AccountAttribute();
+    account.name = name;
+    account.email = name + "@somewhere.com";
+    account.username = name;
+    return Suppliers.ofInstance(account);
+  }
+
+  private void assertSameChangeEvent(ChangeEvent current, ChangeEvent expected) {
+    assertThat(current.changeKey.get()).isEqualTo(expected.changeKey.get());
+    assertThat(current.refName).isEqualTo(expected.refName);
+    assertThat(current.project).isEqualTo(expected.project);
+    assertSameChange(current.change, expected.change);
+  }
+
+  private void assertSameChange(
+      Supplier<ChangeAttribute> currentSupplier, Supplier<ChangeAttribute> expectedSupplier) {
+    ChangeAttribute current = currentSupplier.get();
+    ChangeAttribute expected = expectedSupplier.get();
+    assertThat(current.project).isEqualTo(expected.project);
+    assertThat(current.branch).isEqualTo(expected.branch);
+    assertThat(current.topic).isEqualTo(expected.topic);
+    assertThat(current.id).isEqualTo(expected.id);
+    assertThat(current.number).isEqualTo(expected.number);
+    assertThat(current.subject).isEqualTo(expected.subject);
+    assertThat(current.commitMessage).isEqualTo(expected.commitMessage);
+    assertThat(current.url).isEqualTo(expected.url);
+    assertThat(current.status).isEqualTo(expected.status);
+    assertThat(current.createdOn).isEqualTo(expected.createdOn);
+    assertThat(current.wip).isEqualTo(expected.wip);
+    assertThat(current.isPrivate).isEqualTo(expected.isPrivate);
+  }
+
+  private void assertSameAccount(
+      Supplier<AccountAttribute> currentSupplier, Supplier<AccountAttribute> expectedSupplier) {
+    AccountAttribute current = currentSupplier.get();
+    AccountAttribute expected = expectedSupplier.get();
+    assertThat(current.name).isEqualTo(expected.name);
+    assertThat(current.email).isEqualTo(expected.email);
+    assertThat(current.username).isEqualTo(expected.username);
+  }
+
+  public Supplier<ChangeAttribute> asChangeAttribute(Change change) {
+    ChangeAttribute a = new ChangeAttribute();
+    a.project = change.getProject().get();
+    a.branch = change.getDest().shortName();
+    a.topic = change.getTopic();
+    a.id = change.getKey().get();
+    a.number = change.getId().get();
+    a.subject = change.getSubject();
+    a.commitMessage = "This is a test commit message";
+    a.url = "http://somewhere.com";
+    a.status = change.getStatus();
+    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.wip = change.isWorkInProgress() ? true : null;
+    a.isPrivate = change.isPrivate() ? true : null;
+    return Suppliers.ofInstance(a);
+  }
+
+  @SuppressWarnings("unchecked")
+  private <E extends Event> E roundTrip(E event) {
+    String json = gson.toJson(event);
+    return (E) gson.fromJson(json, event.getClass());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index b59641d..4defda7 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -23,24 +23,22 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.truth.MapSubject;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import org.junit.Before;
 import org.junit.Test;
 
-public class EventJsonTest extends GerritBaseTests {
+public class EventJsonTest {
   private static final String BRANCH = "mybranch";
   private static final String CHANGE_ID = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
   private static final int CHANGE_NUM = 1000;
@@ -52,12 +50,7 @@
   private static final double TS2 = 1.254344401E9;
   private static final String URL = "http://somewhere.com";
 
-  // Must match StreamEvents#gson. (In master, the definition is refactored to be hared.)
-  private final Gson gson =
-      new GsonBuilder()
-          .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-          .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
-          .create();
+  private final Gson gson = new EventGsonProvider().get();
 
   @Before
   public void setTimeForTesting() {
@@ -580,7 +573,7 @@
         Change.key(CHANGE_ID),
         Change.id(CHANGE_NUM),
         Account.id(9999),
-        Branch.nameKey(Project.nameKey(PROJECT), BRANCH),
+        BranchNameKey.create(Project.nameKey(PROJECT), BRANCH),
         TimeUtil.nowTs());
   }
 
@@ -591,7 +584,7 @@
   private Supplier<ChangeAttribute> asChangeAttribute(Change change) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
-    a.branch = change.getDest().getShortName();
+    a.branch = change.getDest().shortName();
     a.topic = change.getTopic();
     a.id = change.getKey().get();
     a.number = change.getId().get();
@@ -609,8 +602,9 @@
     // Parse JSON into a raw Java map:
     //  * Doesn't depend on field iteration order.
     //  * Avoids excessively long string literals in asserts.
+    String json = gson.toJson(src);
     Map<Object, Object> map =
-        gson.fromJson(gson.toJson(src), new TypeToken<Map<Object, Object>>() {}.getType());
+        gson.fromJson(json, new TypeToken<Map<Object, Object>>() {}.getType());
     return assertThat(map);
   }
 
diff --git a/javatests/com/google/gerrit/server/events/EventTypesTest.java b/javatests/com/google/gerrit/server/events/EventTypesTest.java
index dd5c7f9..c822d6c 100644
--- a/javatests/com/google/gerrit/server/events/EventTypesTest.java
+++ b/javatests/com/google/gerrit/server/events/EventTypesTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class EventTypesTest extends GerritBaseTests {
+public class EventTypesTest {
   public static class TestEvent extends Event {
     private static final String TYPE = "test-event";
 
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 4a1f47c..7a1cf51 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendCondition;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Set;
@@ -41,7 +40,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
-public class UiActionsTest extends GerritBaseTests {
+public class UiActionsTest {
 
   private static class FakeForProject extends ForProject {
     private boolean allowValueQueries = true;
@@ -112,7 +111,7 @@
 
         @Override
         public Account.Id getAccountId() {
-          return new Account.Id(1);
+          return Account.id(1);
         }
       };
     }
diff --git a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
index cc648bf..c8df548 100644
--- a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
+++ b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.createMock;
 import static org.easymock.EasyMock.replay;
 
@@ -26,7 +27,6 @@
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
@@ -36,7 +36,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class FixReplacementInterpreterTest extends GerritBaseTests {
+public class FixReplacementInterpreterTest {
   private final FileContentUtil fileContentUtil = createMock(FileContentUtil.class);
   private final Repository repository = createMock(Repository.class);
   private final ProjectState projectState = createMock(ProjectState.class);
@@ -256,9 +256,7 @@
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
     replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -269,8 +267,7 @@
 
     replay(fileContentUtil);
 
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -280,9 +277,7 @@
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
     replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -293,8 +288,7 @@
 
     replay(fileContentUtil);
 
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   @Test
@@ -304,9 +298,7 @@
     mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
 
     replay(fileContentUtil);
-
-    exception.expect(ResourceConflictException.class);
-    toTreeModifications(fixReplacement);
+    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
   }
 
   private void mockFileContent(String filePath, String fileContent) throws Exception {
diff --git a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
index 309f726..ba80c02 100644
--- a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
+++ b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
@@ -15,25 +15,27 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class LineIdentifierTest extends GerritBaseTests {
+public class LineIdentifierTest {
   @Test
   public void lineNumberMustBePositive() {
     LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    exception.expect(StringIndexOutOfBoundsException.class);
-    exception.expectMessage("positive");
-    lineIdentifier.getStartIndexOfLine(0);
+    StringIndexOutOfBoundsException thrown =
+        assertThrows(
+            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(0));
+    assertThat(thrown).hasMessageThat().contains("positive");
   }
 
   @Test
   public void lineNumberMustIndicateAnAvailableLine() {
     LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    exception.expect(StringIndexOutOfBoundsException.class);
-    exception.expectMessage("Line 3 isn't available");
-    lineIdentifier.getStartIndexOfLine(3);
+    StringIndexOutOfBoundsException thrown =
+        assertThrows(
+            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(3));
+    assertThat(thrown).hasMessageThat().contains("Line 3 isn't available");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/fixes/StringModifierTest.java b/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
index 185b58c..3447248 100644
--- a/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
+++ b/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Before;
 import org.junit.Test;
 
-public class StringModifierTest extends GerritBaseTests {
+public class StringModifierTest {
   private final String originalString = "This is the original, unmodified string.";
   private StringModifier stringModifier;
 
@@ -63,20 +63,20 @@
   @Test
   public void replacedPartsMustNotOverlap() {
     stringModifier.replace(0, 9, "");
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(8, 32, "The modified");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(8, 32, "The modified"));
   }
 
   @Test
   public void startIndexMustNotBeGreaterThanEndIndex() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(10, 9, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(10, 9, "something"));
   }
 
   @Test
   public void startIndexMustNotBeNegative() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(-1, 9, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class, () -> stringModifier.replace(-1, 9, "something"));
   }
 
   @Test
@@ -90,13 +90,17 @@
 
   @Test
   public void startIndexMustNotBeGreaterThanLengthOfString() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(originalString.length() + 1, originalString.length() + 1, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class,
+        () ->
+            stringModifier.replace(
+                originalString.length() + 1, originalString.length() + 1, "something"));
   }
 
   @Test
   public void endIndexMustNotBeGreaterThanLengthOfString() {
-    exception.expect(StringIndexOutOfBoundsException.class);
-    stringModifier.replace(8, originalString.length() + 1, "something");
+    assertThrows(
+        StringIndexOutOfBoundsException.class,
+        () -> stringModifier.replace(8, originalString.length() + 1, "something"));
   }
 }
diff --git a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
index f694299..2b59544 100644
--- a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
+++ b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
@@ -21,7 +21,6 @@
 import com.google.common.collect.SortedSetMultimap;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -32,7 +31,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupCollectorTest extends GerritBaseTests {
+public class GroupCollectorTest {
   private TestRepository<?> tr;
 
   @Before
@@ -285,7 +284,7 @@
   // TODO(dborowitz): Tests for octopus merges.
 
   private static PatchSet.Id psId(int c, int p) {
-    return new PatchSet.Id(new Change.Id(c), p);
+    return PatchSet.id(Change.id(c), p);
   }
 
   private RevWalk newWalk(ObjectId start, ObjectId branchTip) throws Exception {
diff --git a/javatests/com/google/gerrit/server/git/JGitConfigTest.java b/javatests/com/google/gerrit/server/git/JGitConfigTest.java
new file mode 100644
index 0000000..7cb5a98
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/JGitConfigTest.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.truth.Truth.assertThat;
+
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class JGitConfigTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private SitePaths site;
+  private Path gitPath;
+
+  @Before
+  public void setUp() throws IOException {
+    site = new SitePaths(temporaryFolder.newFolder().toPath());
+    Files.createDirectories(site.etc_dir);
+    gitPath = Files.createDirectories(site.resolve("git"));
+
+    Files.write(
+        site.jgit_config, "[core]\n  trustFolderStat = false\n".getBytes(StandardCharsets.UTF_8));
+    new SystemReaderInstaller(site).start();
+  }
+
+  @Test
+  public void test() throws IOException {
+    try (Repository repo = new FileRepository(gitPath.resolve("foo").toFile())) {
+      assertThat(repo.getConfig().getString("core", null, "trustFolderStat")).isEqualTo("false");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index 821a6e6b..4e79e33 100644
--- a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -16,11 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.ioutil.HostPlatform;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -36,7 +36,7 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 
-public class LocalDiskRepositoryManagerTest extends GerritBaseTests {
+public class LocalDiskRepositoryManagerTest {
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private Config cfg;
@@ -52,14 +52,15 @@
     repoManager = new LocalDiskRepositoryManager(site, cfg);
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testThatNullBasePathThrowsAnException() {
-    new LocalDiskRepositoryManager(site, new Config());
+    assertThrows(
+        IllegalStateException.class, () -> new LocalDiskRepositoryManager(site, new Config()));
   }
 
   @Test
   public void projectCreation() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     try (Repository repo = repoManager.createRepository(projectA)) {
       assertThat(repo).isNotNull();
     }
@@ -69,112 +70,149 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithEmptyName() throws Exception {
-    repoManager.createRepository(new Project.NameKey(""));
+    assertThrows(
+        RepositoryNotFoundException.class, () -> repoManager.createRepository(Project.nameKey("")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithTrailingSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("projectA/"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("projectA/")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithBackSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a\\projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a\\projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationAbsolutePath() throws Exception {
-    repoManager.createRepository(new Project.NameKey("/projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("/projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationStartingWithDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("../projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("../projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationContainsDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/../projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/../projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationDotPathSegment() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/./projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/./projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithTwoSlashes() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a//projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a//projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPathSegmentEndingByDotGit() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/b.git/projectA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("a/b.git/projectA")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithQuestionMark() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project?A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project?A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPercentageSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project%A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project%A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithWidlcard() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project*A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project*A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithColon() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project:A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project:A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithLessThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project<A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project<A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithGreaterThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project>A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project>A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithPipe() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project|A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project|A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithDollarSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project$A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project$A")));
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testProjectCreationWithCarriageReturn() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project\\rA"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.createRepository(Project.nameKey("project\\rA")));
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testProjectRecreation() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(Project.nameKey("a"));
+    assertThrows(
+        IllegalStateException.class, () -> repoManager.createRepository(Project.nameKey("a")));
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testProjectRecreationAfterRestart() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(Project.nameKey("a"));
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("a"));
+    assertThrows(
+        IllegalStateException.class, () -> newRepoManager.createRepository(Project.nameKey("a")));
   }
 
   @Test
   public void openRepositoryCreatedDirectlyOnDisk() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
     try (Repository repo = repoManager.openRepository(projectA)) {
       assertThat(repo).isNotNull();
@@ -182,30 +220,36 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatch() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("A"));
+    repoManager.createRepository(Project.nameKey("a"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> repoManager.createRepository(Project.nameKey("A")));
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatchWithSymlink() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
+    Project.NameKey name = Project.nameKey("a");
     repoManager.createRepository(name);
     createSymLink(name, "b.git");
-    repoManager.createRepository(new Project.NameKey("B"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> repoManager.createRepository(Project.nameKey("B")));
   }
 
-  @Test(expected = RepositoryCaseMismatchException.class)
+  @Test
   public void testNameCaseMismatchAfterRestart() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
+    Project.NameKey name = Project.nameKey("a");
     repoManager.createRepository(name);
 
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("A"));
+    assertThrows(
+        RepositoryCaseMismatchException.class,
+        () -> newRepoManager.createRepository(Project.nameKey("A")));
   }
 
   private void createSymLink(Project.NameKey project, String link) throws IOException {
@@ -215,20 +259,22 @@
     Files.createSymbolicLink(symlink, projectDir);
   }
 
-  @Test(expected = RepositoryNotFoundException.class)
+  @Test
   public void testOpenRepositoryInvalidName() throws Exception {
-    repoManager.openRepository(new Project.NameKey("project%?|<>A"));
+    assertThrows(
+        RepositoryNotFoundException.class,
+        () -> repoManager.openRepository(Project.nameKey("project%?|<>A")));
   }
 
   @Test
   public void list() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
+    Project.NameKey projectA = Project.nameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
 
-    Project.NameKey projectB = new Project.NameKey("path/projectB");
+    Project.NameKey projectB = Project.nameKey("path/projectB");
     createRepository(repoManager.getBasePath(projectB), projectB.get());
 
-    Project.NameKey projectC = new Project.NameKey("anotherPath/path/projectC");
+    Project.NameKey projectC = Project.nameKey("anotherPath/path/projectC");
     createRepository(repoManager.getBasePath(projectC), projectC.get());
     // create an invalid git repo named only .git
     repoManager.getBasePath(null).resolve(".git").toFile().mkdir();
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index fc79a6d..491594b 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.createNiceMock;
 import static org.easymock.EasyMock.expect;
 import static org.easymock.EasyMock.replay;
@@ -24,7 +25,6 @@
 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.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -41,7 +41,7 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 
-public class MultiBaseLocalDiskRepositoryManagerTest extends GerritBaseTests {
+public class MultiBaseLocalDiskRepositoryManagerTest {
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private Config cfg;
@@ -64,7 +64,7 @@
   @Test
   public void defaultRepositoryLocation()
       throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException {
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    Project.NameKey someProjectKey = Project.nameKey("someProject");
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
@@ -89,7 +89,7 @@
   @Test
   public void alternateRepositoryLocation() throws IOException {
     Path alternateBasePath = temporaryFolder.newFolder().toPath();
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    Project.NameKey someProjectKey = Project.nameKey("someProject");
     reset(configMock);
     expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath).anyTimes();
     expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of(alternateBasePath)).anyTimes();
@@ -116,10 +116,10 @@
 
   @Test
   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 basePathProject = Project.nameKey("basePathProject");
+    Project.NameKey altPathProject = Project.nameKey("altPathProject");
+    Project.NameKey misplacedProject1 = Project.nameKey("misplacedProject1");
+    Project.NameKey misplacedProject2 = Project.nameKey("misplacedProject2");
 
     Path alternateBasePath = temporaryFolder.newFolder().toPath();
 
@@ -150,11 +150,17 @@
     }
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testRelativeAlternateLocation() {
-    configMock = createNiceMock(RepositoryConfig.class);
-    expect(configMock.getAllBasePaths()).andReturn(ImmutableList.of(Paths.get("repos"))).anyTimes();
-    replay(configMock);
-    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+    assertThrows(
+        IllegalStateException.class,
+        () -> {
+          configMock = createNiceMock(RepositoryConfig.class);
+          expect(configMock.getAllBasePaths())
+              .andReturn(ImmutableList.of(Paths.get("repos")))
+              .anyTimes();
+          replay(configMock);
+          repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+        });
   }
 }
diff --git a/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java b/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
index 8c17075..29d89bc 100644
--- a/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
+++ b/javatests/com/google/gerrit/server/git/PureRevertCacheKeyTest.java
@@ -36,8 +36,7 @@
             0xaa, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
             0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb);
 
-    Cache.PureRevertKeyProto key =
-        PureRevertCache.key(new Project.NameKey("test"), revert, original);
+    Cache.PureRevertKeyProto key = PureRevertCache.key(Project.nameKey("test"), revert, original);
     assertThat(key)
         .isEqualTo(
             Cache.PureRevertKeyProto.newBuilder()
diff --git a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
index 87ddc75..e3ab8d0 100644
--- a/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetHolderTest.java
@@ -21,13 +21,12 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class TagSetHolderTest extends GerritBaseTests {
+public class TagSetHolderTest {
   @Test
   public void serializerWithTagSet() throws Exception {
-    TagSetHolder holder = new TagSetHolder(new Project.NameKey("project"));
+    TagSetHolder holder = new TagSetHolder(Project.nameKey("project"));
     holder.setTagSet(new TagSet(holder.getProjectName()));
 
     byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
@@ -46,7 +45,7 @@
 
   @Test
   public void serializerWithoutTagSet() throws Exception {
-    TagSetHolder holder = new TagSetHolder(new Project.NameKey("project"));
+    TagSetHolder holder = new TagSetHolder(Project.nameKey("project"));
 
     byte[] serialized = TagSetHolder.Serializer.INSTANCE.serialize(holder);
     assertThat(TagSetHolderProto.parseFrom(serialized))
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
index 3ac72be..7d90d8c 100644
--- a/javatests/com/google/gerrit/server/git/TagSetTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
 import com.google.gerrit.server.git.TagSet.CachedRef;
 import com.google.gerrit.server.git.TagSet.Tag;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
 import java.util.Arrays;
@@ -43,7 +42,7 @@
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.junit.Test;
 
-public class TagSetTest extends GerritBaseTests {
+public class TagSetTest {
   @Test
   public void roundTripToProto() {
     HashMap<String, CachedRef> refs = new HashMap<>();
@@ -60,7 +59,7 @@
     tags.add(
         new Tag(
             ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"), newBitSet(2, 4, 6)));
-    TagSet tagSet = new TagSet(new Project.NameKey("project"), refs, tags);
+    TagSet tagSet = new TagSet(Project.nameKey("project"), refs, tags);
 
     TagSetProto proto = tagSet.toProto();
     assertThat(proto)
@@ -156,22 +155,24 @@
 
     Map<String, CachedRef> aRefs = a.getRefsForTesting();
     Map<String, CachedRef> bRefs = b.getRefsForTesting();
-    assertThat(ImmutableSortedSet.copyOf(aRefs.keySet()))
-        .named("ref name set")
+    assertWithMessage("ref name set")
+        .that(ImmutableSortedSet.copyOf(aRefs.keySet()))
         .isEqualTo(ImmutableSortedSet.copyOf(bRefs.keySet()));
     for (String name : aRefs.keySet()) {
       CachedRef aRef = aRefs.get(name);
       CachedRef bRef = bRefs.get(name);
-      assertThat(aRef.get()).named("value of ref %s", name).isEqualTo(bRef.get());
-      assertThat(aRef.flag).named("flag of ref %s", name).isEqualTo(bRef.flag);
+      assertWithMessage("value of ref %s", name).that(aRef.get()).isEqualTo(bRef.get());
+      assertWithMessage("flag of ref %s", name).that(aRef.flag).isEqualTo(bRef.flag);
     }
 
     ObjectIdOwnerMap<Tag> aTags = a.getTagsForTesting();
     ObjectIdOwnerMap<Tag> bTags = b.getTagsForTesting();
-    assertThat(getTagIds(aTags)).named("tag ID set").isEqualTo(getTagIds(bTags));
+    assertWithMessage("tag ID set").that(getTagIds(aTags)).isEqualTo(getTagIds(bTags));
     for (Tag aTag : aTags) {
       Tag bTag = bTags.get(aTag);
-      assertThat(aTag.refFlags).named("flags for tag %s", aTag.name()).isEqualTo(bTag.refFlags);
+      assertWithMessage("flags for tag %s", aTag.name())
+          .that(aTag.refFlags)
+          .isEqualTo(bTag.refFlags);
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index dedccc2..e14b526 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.VersionedMetaData.BatchMetaDataUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
 import java.util.Arrays;
@@ -51,7 +50,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class VersionedMetaDataTest extends GerritBaseTests {
+public class VersionedMetaDataTest {
   // If you're considering fleshing out this test and making it more comprehensive, please consider
   // instead coming up with a replacement interface for
   // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
@@ -65,7 +64,7 @@
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-    project = new Project.NameKey("repo");
+    project = Project.nameKey("repo");
     repo = new InMemoryRepository(new DfsRepositoryDescription(project.get()));
   }
 
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 9fc6da1..2acc7dcf 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -44,7 +43,7 @@
 import org.junit.Ignore;
 
 @Ignore
-public class AbstractGroupTest extends GerritBaseTests {
+public class AbstractGroupTest {
   protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
@@ -65,9 +64,9 @@
     allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
-    serverAccountId = new Account.Id(SERVER_ACCOUNT_NUMBER);
+    serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
     serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
-    userId = new Account.Id(USER_ACCOUNT_NUMBER);
+    userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
 
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 309d710..060079f 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAudit;
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.group.InternalGroup;
@@ -66,7 +66,7 @@
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
 
     // User adds account 100002 to the group.
-    Account.Id id = new Account.Id(100002);
+    Account.Id id = Account.id(100002);
     addMembers(uuid, ImmutableSet.of(id));
 
     AccountGroupMemberAudit expAudit2 =
@@ -78,7 +78,7 @@
     // User removes account 100002 from the group.
     removeMembers(uuid, ImmutableSet.of(id));
 
-    expAudit2.removed(userId, getTipTimestamp(uuid));
+    expAudit2 = expAudit2.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
         .containsExactly(expAudit1, expAudit2)
         .inOrder();
@@ -94,8 +94,8 @@
         createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
 
-    Account.Id id1 = new Account.Id(100002);
-    Account.Id id2 = new Account.Id(100003);
+    Account.Id id1 = Account.id(100002);
+    Account.Id id2 = Account.id(100003);
     addMembers(uuid, ImmutableSet.of(id1, id2));
 
     AccountGroupMemberAudit expAudit2 =
@@ -118,13 +118,13 @@
 
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid));
 
-    AccountGroupByIdAud expAudit =
+    AccountGroupByIdAudit expAudit =
         createExpGroupAudit(group.getId(), subgroupUuid, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
 
     removeSubgroups(uuid, ImmutableSet.of(subgroupUuid));
 
-    expAudit.removed(userId, getTipTimestamp(uuid));
+    expAudit = expAudit.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
   }
 
@@ -140,9 +140,9 @@
 
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid2));
 
-    AccountGroupByIdAud expAudit1 =
+    AccountGroupByIdAudit expAudit1 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
-    AccountGroupByIdAud expAudit2 =
+    AccountGroupByIdAudit expAudit2 =
         createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expAudit1, expAudit2)
@@ -158,9 +158,9 @@
         createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expMemberAudit);
 
-    Account.Id id1 = new Account.Id(100002);
-    Account.Id id2 = new Account.Id(100003);
-    Account.Id id3 = new Account.Id(100004);
+    Account.Id id1 = Account.id(100002);
+    Account.Id id2 = Account.id(100003);
+    Account.Id id3 = Account.id(100004);
     InternalGroup subgroup1 = createGroupAsUser(2, "test-group-2");
     InternalGroup subgroup2 = createGroupAsUser(3, "test-group-3");
     InternalGroup subgroup3 = createGroupAsUser(4, "test-group-4");
@@ -180,23 +180,23 @@
 
     // Add one subgroup.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
-    AccountGroupByIdAud expGroupAudit1 =
+    AccountGroupByIdAudit expGroupAudit1 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1);
 
     // Remove one account.
     removeMembers(uuid, ImmutableSet.of(id2));
-    expMemberAudit2.removed(userId, getTipTimestamp(uuid));
+    expMemberAudit2 = expMemberAudit2.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
         .containsExactly(expMemberAudit, expMemberAudit1, expMemberAudit2)
         .inOrder();
 
     // Add two subgroups.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid2, subgroupUuid3));
-    AccountGroupByIdAud expGroupAudit2 =
+    AccountGroupByIdAudit expGroupAudit2 =
         createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
-    AccountGroupByIdAud expGroupAudit3 =
+    AccountGroupByIdAudit expGroupAudit3 =
         createExpGroupAudit(group.getId(), subgroupUuid3, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
@@ -215,15 +215,15 @@
 
     // Remove two subgroups.
     removeSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid3));
-    expGroupAudit1.removed(userId, getTipTimestamp(uuid));
-    expGroupAudit3.removed(userId, getTipTimestamp(uuid));
+    expGroupAudit1 = expGroupAudit1.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
+    expGroupAudit3 = expGroupAudit3.toBuilder().removed(userId, getTipTimestamp(uuid)).build();
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
         .inOrder();
 
     // Add back one removed subgroup.
     addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
-    AccountGroupByIdAud expGroupAudit4 =
+    AccountGroupByIdAudit expGroupAudit4 =
         createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
     assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
         .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3, expGroupAudit4)
@@ -239,8 +239,8 @@
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(GroupUUID.make(groupName, serverIdent))
-            .setNameKey(new AccountGroup.NameKey(groupName))
-            .setId(new AccountGroup.Id(next))
+            .setNameKey(AccountGroup.nameKey(groupName))
+            .setId(AccountGroup.id(next))
             .build();
     InternalGroupUpdate groupUpdate =
         authorIdent.equals(serverIdent)
@@ -303,12 +303,21 @@
 
   private static AccountGroupMemberAudit createExpMemberAudit(
       AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
-    return new AccountGroupMemberAudit(
-        new AccountGroupMemberAudit.Key(id, groupId, addedOn), addedBy);
+    return AccountGroupMemberAudit.builder()
+        .groupId(groupId)
+        .memberId(id)
+        .addedOn(addedOn)
+        .addedBy(addedBy)
+        .build();
   }
 
-  private static AccountGroupByIdAud createExpGroupAudit(
+  private static AccountGroupByIdAudit createExpGroupAudit(
       AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
-    return new AccountGroupByIdAud(new AccountGroupByIdAud.Key(groupId, uuid, addedOn), addedBy);
+    return AccountGroupByIdAudit.builder()
+        .groupId(groupId)
+        .includeUuid(uuid)
+        .addedOn(addedOn)
+        .addedBy(addedBy)
+        .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index c9ba72e..1d75229 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -17,8 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.MatcherAssert.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -33,7 +35,6 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -55,20 +56,20 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupConfigTest extends GerritBaseTests {
+public class GroupConfigTest {
   private Project.NameKey projectName;
   private Repository repository;
   private TestRepository<?> testRepository;
-  private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
-  private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
-  private final AccountGroup.Id groupId = new AccountGroup.Id(123);
+  private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
+  private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
+  private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
   private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
-    projectName = new Project.NameKey("Test Repository");
+    projectName = Project.nameKey("Test Repository");
     repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
     testRepository = new TestRepository<>(repository);
   }
@@ -95,7 +96,7 @@
 
   @Test
   public void nameOfGroupUpdateOverridesGroupCreation() throws Exception {
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("Another name");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("Another name");
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(groupName).build();
@@ -109,26 +110,13 @@
   @Test
   public void nameOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey("")).build();
+        getPrefilledGroupCreationBuilder().setNameKey(AccountGroup.nameKey("")).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
-  public void nameOfNewGroupMustNotBeNull() throws Exception {
-    InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
 
@@ -144,13 +132,13 @@
   @Test
   public void idOfNewGroupMustNotBeNegative() throws Exception {
     InternalGroupCreation groupCreation =
-        getPrefilledGroupCreationBuilder().setId(new AccountGroup.Id(-2)).build();
+        getPrefilledGroupCreationBuilder().setId(AccountGroup.id(-2)).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("ID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
     }
   }
 
@@ -207,7 +195,7 @@
 
   @Test
   public void specifiedOwnerGroupUuidIsRespectedForNewGroup() throws Exception {
-    AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID("anotherOwnerUuid");
+    AccountGroup.UUID ownerGroupUuid = AccountGroup.uuid("anotherOwnerUuid");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -219,32 +207,17 @@
   }
 
   @Test
-  public void ownerGroupUuidOfNewGroupMustNotBeNull() throws Exception {
-    InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   public void ownerGroupUuidOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
 
@@ -303,8 +276,8 @@
 
   @Test
   public void specifiedMembersAreRespectedForNewGroup() throws Exception {
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -319,8 +292,8 @@
 
   @Test
   public void specifiedSubgroupsAreRespectedForNewGroup() throws Exception {
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroup1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroup2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroup1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroup2");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     InternalGroupUpdate groupUpdate =
@@ -353,9 +326,11 @@
   public void idInConfigMustBeDefined() throws Exception {
     populateGroupConfig(groupUuid, "[group]\n\tname = users\n\townerGroupUuid = owners\n");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(projectName, repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
   }
 
   @Test
@@ -363,9 +338,11 @@
     populateGroupConfig(
         groupUuid, "[group]\n\tname = users\n\tid = -5\n\townerGroupUuid = owners\n");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("ID of the group " + groupUuid);
-    GroupConfig.loadForGroup(projectName, repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
   }
 
   @Test
@@ -389,9 +366,11 @@
   public void ownerGroupUuidInConfigMustBeDefined() throws Exception {
     populateGroupConfig(groupUuid, "[group]\n\tname = users\n\tid = 42\n");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("Owner UUID of the group " + groupUuid);
-    GroupConfig.loadForGroup(projectName, repository, groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () -> GroupConfig.loadForGroup(projectName, repository, groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
   }
 
   @Test
@@ -430,12 +409,12 @@
         .value()
         .members()
         .containsExactly(
-            new Account.Id(1),
-            new Account.Id(2),
-            new Account.Id(3),
-            new Account.Id(4),
-            new Account.Id(5),
-            new Account.Id(6));
+            Account.id(1),
+            Account.id(2),
+            Account.id(3),
+            Account.id(4),
+            Account.id(5),
+            Account.id(6));
   }
 
   @Test
@@ -443,9 +422,9 @@
     populateGroupConfig(groupUuid, "[group]\n\tname=users\n\tid = 42\n\townerGroupUuid = owners\n");
     populateMembersFile(groupUuid, "One");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("Invalid file members");
-    loadGroup(groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(ConfigInvalidException.class, () -> loadGroup(groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Invalid file members");
   }
 
   @Test
@@ -453,9 +432,9 @@
     populateGroupConfig(groupUuid, "[group]\n\tname=users\n\tid = 42\n\townerGroupUuid = owners\n");
     populateMembersFile(groupUuid, "1\t2");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage("Invalid file members");
-    loadGroup(groupUuid);
+    ConfigInvalidException thrown =
+        assertThrows(ConfigInvalidException.class, () -> loadGroup(groupUuid));
+    assertThat(thrown).hasMessageThat().contains("Invalid file members");
   }
 
   @Test
@@ -494,12 +473,12 @@
         .value()
         .subgroups()
         .containsExactly(
-            new AccountGroup.UUID("1"),
-            new AccountGroup.UUID("2"),
-            new AccountGroup.UUID("3"),
-            new AccountGroup.UUID("4"),
-            new AccountGroup.UUID("5"),
-            new AccountGroup.UUID("6"));
+            AccountGroup.uuid("1"),
+            AccountGroup.uuid("2"),
+            AccountGroup.uuid("3"),
+            AccountGroup.uuid("4"),
+            AccountGroup.uuid("5"),
+            AccountGroup.uuid("6"));
   }
 
   @Test
@@ -508,7 +487,7 @@
     populateSubgroupsFile(groupUuid, "1\t2 3");
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
-    assertThatGroup(group).value().subgroups().containsExactly(new AccountGroup.UUID("1\t2 3"));
+    assertThatGroup(group).value().subgroups().containsExactly(AccountGroup.uuid("1\t2 3"));
   }
 
   @Test
@@ -520,13 +499,13 @@
     assertThatGroup(group)
         .value()
         .subgroups()
-        .containsExactly(new AccountGroup.UUID("1\t2"), new AccountGroup.UUID("3"));
+        .containsExactly(AccountGroup.uuid("1\t2"), AccountGroup.uuid("3"));
   }
 
   @Test
   public void nameCanBeUpdated() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.NameKey newName = new AccountGroup.NameKey("New name");
+    AccountGroup.NameKey newName = AccountGroup.nameKey("New name");
 
     InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(newName).build();
     updateGroup(groupUuid, groupUpdate);
@@ -536,41 +515,25 @@
   }
 
   @Test
-  public void nameCannotBeUpdatedToNull() throws Exception {
-    createArbitraryGroup(groupUuid);
-
-    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(null)).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   public void nameCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Name of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
 
   @Test
   public void nameCanBeUpdatedToEmptyStringIfExplicitlySpecified() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
+    AccountGroup.NameKey emptyName = AccountGroup.nameKey("");
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setAllowSaveEmptyName();
@@ -608,7 +571,7 @@
   @Test
   public void ownerGroupUuidCanBeUpdated() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID newOwnerGroupUuid = new AccountGroup.UUID("New owner");
+    AccountGroup.UUID newOwnerGroupUuid = AccountGroup.uuid("New owner");
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setOwnerGroupUUID(newOwnerGroupUuid).build();
@@ -619,34 +582,18 @@
   }
 
   @Test
-  public void ownerGroupUuidCannotBeUpdatedToNull() throws Exception {
-    createArbitraryGroup(groupUuid);
-
-    GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
-
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
-    }
-  }
-
-  @Test
   public void ownerGroupUuidCannotBeUpdatedToEmptyString() throws Exception {
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      exception.expectCause(instanceOf(ConfigInvalidException.class));
-      exception.expectMessage("Owner UUID of the group " + groupUuid);
-      groupConfig.commit(metaDataUpdate);
+      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
 
@@ -675,7 +622,7 @@
 
     InternalGroupUpdate laterGroupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     Optional<InternalGroup> group = updateGroup(groupCreation.getGroupUUID(), laterGroupUpdate);
@@ -688,8 +635,8 @@
   @Test
   public void membersCanBeAdded() throws Exception {
     createArbitraryGroup(groupUuid);
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -710,8 +657,8 @@
   @Test
   public void membersCanBeDeleted() throws Exception {
     createArbitraryGroup(groupUuid);
-    Account.Id member1 = new Account.Id(1);
-    Account.Id member2 = new Account.Id(2);
+    Account.Id member1 = Account.id(1);
+    Account.Id member2 = Account.id(2);
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -732,8 +679,8 @@
   @Test
   public void subgroupsCanBeAdded() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroups1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroups2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -754,8 +701,8 @@
   @Test
   public void subgroupsCanBeDeleted() throws Exception {
     createArbitraryGroup(groupUuid);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("subgroups1");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("subgroups2");
+    AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
+    AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
@@ -798,13 +745,12 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
     Optional<InternalGroup> createdGroup = createGroup(groupCreation, groupUpdate);
@@ -820,13 +766,12 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
     Optional<InternalGroup> updatedGroup = updateGroup(groupUuid, groupUpdate);
@@ -843,19 +788,18 @@
     InternalGroupUpdate initialGroupUpdate =
         InternalGroupUpdate.builder()
             .setDescription("A test group")
-            .setOwnerGroupUUID(new AccountGroup.UUID("another owner"))
+            .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(92900892))
-            .setMemberModification(members -> ImmutableSet.of(new Account.Id(1), new Account.Id(2)))
-            .setSubgroupModification(
-                subgroups -> ImmutableSet.of(new AccountGroup.UUID("subgroup")))
+            .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
+            .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
     createGroup(groupCreation, initialGroupUpdate);
 
     // Only update one of the properties.
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
 
     Optional<InternalGroup> updatedGroup = updateGroup(groupCreation.getGroupUUID(), groupUpdate);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupCreation.getGroupUUID());
@@ -870,7 +814,7 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     commit(groupConfig);
 
-    AccountGroup.NameKey name = new AccountGroup.NameKey("Robots");
+    AccountGroup.NameKey name = AccountGroup.nameKey("Robots");
     InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(name).build();
     groupConfig.setGroupUpdate(groupUpdate1, auditLogFormatter);
     commit(groupConfig);
@@ -907,7 +851,7 @@
     RevCommit commitAfterCreation = getLatestCommitForGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
@@ -990,9 +934,7 @@
     createArbitraryGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
-            .setOwnerGroupUUID(new AccountGroup.UUID("Another owner"))
-            .build();
+        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("Another owner")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
@@ -1008,8 +950,7 @@
 
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setMemberModification(
-                members -> Sets.union(members, ImmutableSet.of(new Account.Id(10))))
+            .setMemberModification(members -> Sets.union(members, ImmutableSet.of(Account.id(10))))
             .build();
     updateGroup(groupUuid, groupUpdate);
 
@@ -1027,8 +968,7 @@
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
             .setSubgroupModification(
-                subgroups ->
-                    Sets.union(subgroups, ImmutableSet.of(new AccountGroup.UUID("subgroup"))))
+                subgroups -> Sets.union(subgroups, ImmutableSet.of(AccountGroup.uuid("subgroup"))))
             .build();
     updateGroup(groupUuid, groupUpdate);
 
@@ -1045,7 +985,7 @@
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
 
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
 
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
@@ -1128,7 +1068,7 @@
             .build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
@@ -1161,7 +1101,7 @@
             .build();
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
@@ -1187,7 +1127,7 @@
 
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     updateGroup(groupUuid, groupUpdate);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1202,7 +1142,7 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
             .build();
     updateGroup(groupUuid, groupUpdate);
@@ -1220,7 +1160,7 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
@@ -1248,7 +1188,7 @@
     createArbitraryGroup(groupUuid);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Another name"))
+            .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
@@ -1281,14 +1221,14 @@
   public void groupCanBeLoadedAtASpecificRevision() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    AccountGroup.NameKey firstName = new AccountGroup.NameKey("Bots");
+    AccountGroup.NameKey firstName = AccountGroup.nameKey("Bots");
     InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(firstName).build();
     updateGroup(groupUuid, groupUpdate1);
 
     RevCommit commitAfterUpdate1 = getLatestCommitForGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Robots")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Robots")).build();
     updateGroup(groupUuid, groupUpdate2);
 
     GroupConfig groupConfig =
@@ -1315,7 +1255,7 @@
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
     InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Another name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
     createGroup(groupCreation, groupUpdate);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1324,8 +1264,8 @@
 
   @Test
   public void commitMessageOfNewGroupWithMembersContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     AuditLogFormatter auditLogFormatter =
@@ -1349,8 +1289,8 @@
 
   @Test
   public void commitMessageOfNewGroupWithSubgroupsContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     AuditLogFormatter auditLogFormatter =
@@ -1374,8 +1314,8 @@
 
   @Test
   public void commitMessageOfMemberAdditionContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     createArbitraryGroup(groupUuid);
@@ -1396,8 +1336,8 @@
 
   @Test
   public void commitMessageOfMemberRemovalContainsFooters() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
 
     createArbitraryGroup(groupUuid);
@@ -1423,8 +1363,8 @@
 
   @Test
   public void commitMessageOfSubgroupAdditionContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1446,8 +1386,8 @@
 
   @Test
   public void commitMessageOfSubgroupRemovalContainsFooters() throws Exception {
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1478,11 +1418,11 @@
     createArbitraryGroup(groupUuid);
 
     InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("Old name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Old name")).build();
     updateGroup(groupUuid, groupUpdate1);
 
     InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("New name")).build();
+        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("New name")).build();
     updateGroup(groupUuid, groupUpdate2);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1492,11 +1432,11 @@
 
   @Test
   public void commitMessageFootersCanBeMixed() throws Exception {
-    Account account13 = createAccount(new Account.Id(13), "John");
-    Account account7 = createAccount(new Account.Id(7), "Jane");
+    Account account13 = createAccount(Account.id(13), "John");
+    Account account7 = createAccount(Account.id(7), "Jane");
     ImmutableSet<Account> accounts = ImmutableSet.of(account13, account7);
-    GroupDescription.Basic group1 = createGroup(new AccountGroup.UUID("129403"), "Bots");
-    GroupDescription.Basic group2 = createGroup(new AccountGroup.UUID("8903493"), "Verifiers");
+    GroupDescription.Basic group1 = createGroup(AccountGroup.uuid("129403"), "Bots");
+    GroupDescription.Basic group2 = createGroup(AccountGroup.uuid("8903493"), "Verifiers");
     ImmutableSet<GroupDescription.Basic> groups = ImmutableSet.of(group1, group2);
 
     createArbitraryGroup(groupUuid);
@@ -1506,7 +1446,7 @@
 
     InternalGroupUpdate groupUpdate1 =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("Old name"))
+            .setName(AccountGroup.nameKey("Old name"))
             .setMemberModification(members -> ImmutableSet.of(account7.getId()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group2.getGroupUUID()))
             .build();
@@ -1514,7 +1454,7 @@
 
     InternalGroupUpdate groupUpdate2 =
         InternalGroupUpdate.builder()
-            .setName(new AccountGroup.NameKey("New name"))
+            .setName(AccountGroup.nameKey("New name"))
             .setMemberModification(members -> ImmutableSet.of(account13.getId()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group1.getGroupUUID()))
             .build();
@@ -1623,7 +1563,7 @@
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
-            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repository);
+            GitReferenceUpdated.DISABLED, Project.nameKey("Test Repository"), repository);
     metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
     metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
     return metaDataUpdate;
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index e1211d3..3bcc199 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.commits;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -39,7 +40,6 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.GitTestUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gerrit.truth.ListSubject;
@@ -69,13 +69,13 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupNameNotesTest extends GerritBaseTests {
+public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
   private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
-  private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
-  private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
+  private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
+  private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
 
   private AtomicInteger idCounter;
   private AllUsersName allUsersName;
@@ -105,19 +105,21 @@
 
   @Test
   public void uuidOfNewGroupMustNotBeNull() throws Exception {
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(allUsersName, repo, null, groupName);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forNewGroup(allUsersName, repo, null, groupName));
   }
 
   @Test
   public void nameOfNewGroupMustNotBeNull() throws Exception {
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, null);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forNewGroup(allUsersName, repo, groupUuid, null));
   }
 
   @Test
   public void nameOfNewGroupMayBeEmpty() throws Exception {
-    AccountGroup.NameKey emptyName = new AccountGroup.NameKey("");
+    AccountGroup.NameKey emptyName = AccountGroup.nameKey("");
     createGroup(groupUuid, emptyName);
 
     Optional<GroupReference> groupReference = loadGroup(emptyName);
@@ -128,17 +130,19 @@
   public void newGroupMustNotReuseNameOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("AnotherGroup");
-    exception.expect(DuplicateKeyException.class);
-    exception.expectMessage(groupName.get());
-    GroupNameNotes.forNewGroup(allUsersName, repo, anotherGroupUuid, groupName);
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("AnotherGroup");
+    DuplicateKeyException thrown =
+        assertThrows(
+            DuplicateKeyException.class,
+            () -> GroupNameNotes.forNewGroup(allUsersName, repo, anotherGroupUuid, groupName));
+    assertThat(thrown).hasMessageThat().contains(groupName.get());
   }
 
   @Test
   public void newGroupMayReuseUuidOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     createGroup(groupUuid, anotherName);
 
     Optional<GroupReference> group1 = loadGroup(groupName);
@@ -151,7 +155,7 @@
   public void groupCanBeRenamed() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     Optional<GroupReference> groupReference = loadGroup(anotherName);
@@ -163,7 +167,7 @@
   public void previousNameOfGroupCannotBeUsedAfterRename() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     Optional<GroupReference> group = loadGroup(groupName);
@@ -173,61 +177,75 @@
   @Test
   public void groupCannotBeRenamedToNull() throws Exception {
     createGroup(groupUuid, groupName);
-
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, null);
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, null));
   }
 
   @Test
   public void oldNameOfGroupMustBeSpecifiedForRename() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, null, anotherName);
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, groupUuid, null, anotherName));
   }
 
   @Test
   public void groupCannotBeRenamedWhenOldNameIsWrong() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherOldName = new AccountGroup.NameKey("contributors");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage(anotherOldName.get());
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, anotherOldName, anotherName);
+    AccountGroup.NameKey anotherOldName = AccountGroup.nameKey("contributors");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, groupUuid, anotherOldName, anotherName));
+    assertThat(thrown).hasMessageThat().contains(anotherOldName.get());
   }
 
   @Test
   public void groupCannotBeRenamedToNameOfAnotherGroup() throws Exception {
     createGroup(groupUuid, groupName);
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherGroupName = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherGroupName = AccountGroup.nameKey("admins");
     createGroup(anotherGroupUuid, anotherGroupName);
 
-    exception.expect(DuplicateKeyException.class);
-    exception.expectMessage(anotherGroupName.get());
-    GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherGroupName);
+    DuplicateKeyException thrown =
+        assertThrows(
+            DuplicateKeyException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, groupUuid, groupName, anotherGroupName));
+    assertThat(thrown).hasMessageThat().contains(anotherGroupName.get());
   }
 
   @Test
   public void groupCannotBeRenamedWithoutSpecifiedUuid() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(NullPointerException.class);
-    GroupNameNotes.forRename(allUsersName, repo, null, groupName, anotherName);
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    assertThrows(
+        NullPointerException.class,
+        () -> GroupNameNotes.forRename(allUsersName, repo, null, groupName, anotherName));
   }
 
   @Test
   public void groupCannotBeRenamedWhenUuidIsWrong() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage(groupUuid.get());
-    GroupNameNotes.forRename(allUsersName, repo, anotherGroupUuid, groupName, anotherName);
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
+    ConfigInvalidException thrown =
+        assertThrows(
+            ConfigInvalidException.class,
+            () ->
+                GroupNameNotes.forRename(
+                    allUsersName, repo, anotherGroupUuid, groupName, anotherName));
+    assertThat(thrown).hasMessageThat().contains(groupUuid.get());
   }
 
   @Test
@@ -248,8 +266,8 @@
     createGroup(groupUuid, groupName);
     ImmutableList<CommitInfo> commitsAfterCreation = log();
 
-    AccountGroup.UUID anotherGroupUuid = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID anotherGroupUuid = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     createGroup(anotherGroupUuid, anotherName);
 
     ImmutableList<CommitInfo> commitsAfterFurtherGroup = log();
@@ -262,7 +280,7 @@
     createGroup(groupUuid, groupName);
     ImmutableList<CommitInfo> commitsAfterCreation = log();
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     ImmutableList<CommitInfo> commitsAfterRename = log();
@@ -298,7 +316,7 @@
   public void newCommitIsNotCreatedWhenCommittingGroupRenamingTwice() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     GroupNameNotes groupNameNotes =
         GroupNameNotes.forRename(allUsersName, repo, groupUuid, groupName, anotherName);
 
@@ -323,7 +341,7 @@
   public void commitMessageMentionsGroupRenaming() throws Exception {
     createGroup(groupUuid, groupName);
 
-    AccountGroup.NameKey anotherName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherName = AccountGroup.nameKey("admins");
     renameGroup(groupUuid, groupName, anotherName);
 
     ImmutableList<CommitInfo> commits = log();
@@ -341,18 +359,18 @@
 
   @Test
   public void nonExistentGroupCannotBeLoaded() throws Exception {
-    createGroup(new AccountGroup.UUID("contributors-MN"), new AccountGroup.NameKey("contributors"));
+    createGroup(AccountGroup.uuid("contributors-MN"), AccountGroup.nameKey("contributors"));
     createGroup(groupUuid, groupName);
 
-    Optional<GroupReference> group = loadGroup(new AccountGroup.NameKey("admins"));
+    Optional<GroupReference> group = loadGroup(AccountGroup.nameKey("admins"));
     assertThatGroup(group).isAbsent();
   }
 
   @Test
   public void specificGroupCanBeLoaded() throws Exception {
-    createGroup(new AccountGroup.UUID("contributors-MN"), new AccountGroup.NameKey("contributors"));
+    createGroup(AccountGroup.uuid("contributors-MN"), AccountGroup.nameKey("contributors"));
     createGroup(groupUuid, groupName);
-    createGroup(new AccountGroup.UUID("admins-ABC"), new AccountGroup.NameKey("admins"));
+    createGroup(AccountGroup.uuid("admins-ABC"), AccountGroup.nameKey("admins"));
 
     Optional<GroupReference> group = loadGroup(groupName);
     assertThatGroup(group).value().groupUuid().isEqualTo(groupUuid);
@@ -367,11 +385,11 @@
 
   @Test
   public void allGroupsCanBeLoaded() throws Exception {
-    AccountGroup.UUID groupUuid1 = new AccountGroup.UUID("contributors-MN");
-    AccountGroup.NameKey groupName1 = new AccountGroup.NameKey("contributors");
+    AccountGroup.UUID groupUuid1 = AccountGroup.uuid("contributors-MN");
+    AccountGroup.NameKey groupName1 = AccountGroup.nameKey("contributors");
     createGroup(groupUuid1, groupName1);
-    AccountGroup.UUID groupUuid2 = new AccountGroup.UUID("admins-ABC");
-    AccountGroup.NameKey groupName2 = new AccountGroup.NameKey("admins");
+    AccountGroup.UUID groupUuid2 = AccountGroup.uuid("admins-ABC");
+    AccountGroup.NameKey groupName2 = AccountGroup.nameKey("admins");
     createGroup(groupUuid2, groupName2);
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
@@ -384,7 +402,7 @@
   @Test
   public void loadedGroupsContainGroupsWithDuplicateGroupUuids() throws Exception {
     createGroup(groupUuid, groupName);
-    AccountGroup.NameKey anotherGroupName = new AccountGroup.NameKey("admins");
+    AccountGroup.NameKey anotherGroupName = AccountGroup.nameKey("admins");
     createGroup(groupUuid, anotherGroupName);
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
@@ -427,7 +445,7 @@
     TestRepository<?> tr = new TestRepository<>(repo);
     ObjectId k1 = getNoteKey(g1);
     ObjectId k2 = getNoteKey(g2);
-    ObjectId k3 = GroupNameNotes.getNoteKey(new AccountGroup.NameKey("c"));
+    ObjectId k3 = GroupNameNotes.getNoteKey(AccountGroup.nameKey("c"));
     PersonIdent ident = newPersonIdent();
     ObjectId origCommitId =
         tr.branch(REFS_GROUPNAMES)
@@ -480,14 +498,14 @@
   @Test
   public void updateGroupNamesRejectsNonOneToOneGroupReferences() throws Exception {
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name2"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid1"), "name2"));
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid2"), "name1"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid2"), "name1"));
     assertIllegalArgument(
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
-        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"));
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
+        new GroupReference(AccountGroup.uuid("uuid1"), "name1"));
   }
 
   @Test
@@ -528,8 +546,7 @@
     PersonIdent serverIdent = newPersonIdent();
 
     MetaDataUpdate metaDataUpdate =
-        new MetaDataUpdate(
-            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repo);
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, Project.nameKey("Test Repository"), repo);
     metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
     metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
     return metaDataUpdate;
@@ -537,7 +554,7 @@
 
   private GroupReference newGroup(String name) {
     int id = idCounter.incrementAndGet();
-    return new GroupReference(new AccountGroup.UUID(name + "-" + id), name);
+    return new GroupReference(AccountGroup.uuid(name + "-" + id), name);
   }
 
   private static PersonIdent newPersonIdent() {
@@ -545,7 +562,7 @@
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
-    return GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g.getName()));
+    return GroupNameNotes.getNoteKey(AccountGroup.nameKey(g.getName()));
   }
 
   private void updateAllGroups(PersonIdent ident, GroupReference... groupRefs) throws Exception {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
index a5b04ee..040ad83 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
@@ -30,7 +30,7 @@
   public void groupNamesRefIsMissing() throws Exception {
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
   }
@@ -40,7 +40,7 @@
     updateGroupNamesRef("g-2", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
   }
@@ -50,7 +50,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-1\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems).isEmpty();
   }
 
@@ -59,7 +59,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-1\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -72,7 +72,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(warning("group note of name 'g-1' claims to represent name of 'g-2'"));
   }
@@ -82,7 +82,7 @@
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -97,7 +97,7 @@
     updateGroupNamesRef("g-1", "[invalid");
     List<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
-            allUsersRepo, new AccountGroup.NameKey("g-1"), new AccountGroup.UUID("uuid-1"));
+            allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
         .containsExactly(
             warning(
@@ -105,7 +105,7 @@
   }
 
   private void updateGroupNamesRef(String groupName, String content) throws Exception {
-    String nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(groupName)).getName();
+    String nameKey = GroupNameNotes.getNoteKey(AccountGroup.nameKey(groupName)).getName();
     GroupTestUtil.updateGroupFile(
         allUsersRepo, serverIdent, RefNames.REFS_GROUPNAMES, nameKey, content);
   }
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index c69fa20..5573be7 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -28,16 +28,15 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class AccountFieldTest extends GerritBaseTests {
+public class AccountFieldTest {
   @Test
   public void refStateFieldValues() throws Exception {
     AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account account = new Account(Account.id(1), TimeUtil.nowTs());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
     List<String> values =
@@ -50,7 +49,7 @@
 
   @Test
   public void externalIdStateFieldValues() throws Exception {
-    Account.Id id = new Account.Id(1);
+    Account.Id id = Account.id(1);
     Account account = new Account(id, TimeUtil.nowTs());
     ExternalId extId1 =
         ExternalId.create(
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 758c304..4defea5 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.sql.Timestamp;
 import java.util.Collections;
@@ -38,7 +38,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class ChangeFieldTest extends GerritBaseTests {
+public class ChangeFieldTest {
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
@@ -53,9 +53,9 @@
   public void reviewerFieldValues() {
     Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
     Timestamp t1 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.REVIEWER, new Account.Id(1), t1);
+    t.put(ReviewerStateInternal.REVIEWER, Account.id(1), t1);
     Timestamp t2 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.CC, new Account.Id(2), t2);
+    t.put(ReviewerStateInternal.CC, Account.id(2), t2);
     ReviewerSet reviewers = ReviewerSet.fromTable(t);
 
     List<String> values = ChangeField.getReviewerFieldValues(reviewers);
@@ -63,7 +63,7 @@
         .containsExactly(
             "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
 
-    assertThat(ChangeField.parseReviewerFieldValues(new Change.Id(1), values)).isEqualTo(reviewers);
+    assertThat(ChangeField.parseReviewerFieldValues(Change.id(1), values)).isEqualTo(reviewers);
   }
 
   @Test
@@ -75,7 +75,7 @@
                         SubmitRecord.Status.OK,
                         label(SubmitRecord.Label.Status.MAY, "Label-1", null),
                         label(SubmitRecord.Label.Status.OK, "Label-2", 1))),
-                new Account.Id(1)))
+                Account.id(1)))
         .containsExactly("OK", "MAY,label-1", "OK,label-2", "OK,label-2,0", "OK,label-2,1");
   }
 
@@ -142,7 +142,7 @@
     l.status = status;
     l.label = label;
     if (appliedBy != null) {
-      l.appliedBy = new Account.Id(appliedBy);
+      l.appliedBy = Account.id(appliedBy);
     }
     return l;
   }
@@ -153,8 +153,8 @@
         ChangeField.storedSubmitRecords(recordList).stream()
             .map(s -> new String(s, UTF_8))
             .collect(toList());
-    assertThat(ChangeField.parseSubmitRecords(stored))
-        .named("JSON %s" + stored)
+    assertWithMessage("JSON %s" + stored)
+        .that(ChangeField.parseSubmitRecords(stored))
         .isEqualTo(recordList);
   }
 }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index fd23da3..62b1cbc 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
 import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
 import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableSet;
@@ -34,14 +35,13 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.OrSource;
-import com.google.gerrit.testing.GerritBaseTests;
 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 {
+public class ChangeIndexRewriterTest {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
 
   private FakeChangeIndex index;
@@ -196,9 +196,8 @@
 
     indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
 
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("Unsupported index predicate: file:a");
-    rewrite(in);
+    QueryParseException thrown = assertThrows(QueryParseException.class, () -> rewrite(in));
+    assertThat(thrown).hasMessageThat().contains("Unsupported index predicate: file:a");
   }
 
   @Test
@@ -207,9 +206,9 @@
     Predicate<ChangeData> in = parse(q);
     assertEquals(query(in), rewrite(in));
 
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("too many terms in query");
-    rewrite(parse(q + " OR file:d"));
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> rewrite(parse(q + " OR file:d")));
+    assertThat(thrown).hasMessageThat().contains("too many terms in query");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index a38eabe..59e8f10 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.util.stream.Stream;
 import org.eclipse.jgit.junit.TestRepository;
@@ -37,14 +36,14 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class StalenessCheckerTest extends GerritBaseTests {
+public class StalenessCheckerTest {
   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 Project.NameKey P1 = Project.nameKey("project1");
+  private static final Project.NameKey P2 = Project.nameKey("project2");
 
-  private static final Change.Id C = new Change.Id(1234);
+  private static final Change.Id C = Change.id(1234);
 
   private GitRepositoryManager repoManager;
   private Repository r1;
diff --git a/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java b/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
index c4f32c7..fae8559 100644
--- a/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
@@ -23,14 +23,13 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import org.junit.Test;
 
-public class BasicSerializationTest extends GerritBaseTests {
+public class BasicSerializationTest {
   @Test
   public void testReadVarInt32() throws IOException {
     assertEquals(0x00000000, readVarInt32(r(b(0))));
diff --git a/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java b/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
index 9f5e60a..fe642ba 100644
--- a/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.server.ioutil;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import org.junit.Assert;
 import org.junit.Test;
 
-public class ColumnFormatterTest extends GerritBaseTests {
+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.
diff --git a/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
index 40fd71f..9bb6951 100644
--- a/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/HexFormatTest.java
@@ -16,10 +16,9 @@
 
 import static org.junit.Assert.assertEquals;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class HexFormatTest extends GerritBaseTests {
+public class HexFormatTest {
 
   @Test
   public void fromInt() {
diff --git a/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
index 33b1c4f..3043985 100644
--- a/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
@@ -18,11 +18,10 @@
 import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import org.junit.Test;
 
-public class RegexListSearcherTest extends GerritBaseTests {
+public class RegexListSearcherTest {
   private static final ImmutableList<String> EMPTY = ImmutableList.of();
 
   @Test
diff --git a/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java b/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
index 817b317..04f806d 100644
--- a/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
@@ -16,10 +16,9 @@
 
 import static org.junit.Assert.assertEquals;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class StringUtilTest extends GerritBaseTests {
+public class StringUtilTest {
   /** Test the boundary condition that the first character of a string should be escaped. */
   @Test
   public void escapeFirstChar() {
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 463decf..5117c01 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.truth.Expect;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import java.util.concurrent.ExecutorService;
@@ -25,7 +24,7 @@
 import org.junit.Rule;
 import org.junit.Test;
 
-public class LoggingContextAwareExecutorServiceTest extends GerritBaseTests {
+public class LoggingContextAwareExecutorServiceTest {
   @Rule public final Expect expect = Expect.create();
 
   @Test
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index 113f26c..4fadbb4 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -20,14 +20,13 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Map;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import org.junit.Before;
 import org.junit.Test;
 
-public class MutableTagsTest extends GerritBaseTests {
+public class MutableTagsTest {
   private MutableTags tags;
 
   @Before
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 19b2eeb..044d237 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -19,14 +19,13 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Map;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import org.junit.After;
 import org.junit.Test;
 
-public class TraceContextTest extends GerritBaseTests {
+public class TraceContextTest {
   @After
   public void cleanup() {
     LoggingContext.getInstance().clearTags();
diff --git a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
index f8a613a..9dcb08c 100644
--- a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -18,11 +18,10 @@
 
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailMessage;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.time.Instant;
 import org.junit.Test;
 
-public class AutoReplyMailFilterTest extends GerritBaseTests {
+public class AutoReplyMailFilterTest {
 
   private AutoReplyMailFilter autoReplyMailFilter = new AutoReplyMailFilter();
 
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
index 78116ed..f4fbc78 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
@@ -20,11 +20,10 @@
 import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
 import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.List;
 import org.junit.Test;
 
-public class CommentFormatterTest extends GerritBaseTests {
+public class CommentFormatterTest {
   private void assertBlock(
       List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
     CommentFormatter.Block block = list.get(index);
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
index 0682bb3..78cefdf 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -16,11 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collections;
 import org.junit.Test;
 
-public class CommentSenderTest extends GerritBaseTests {
+public class CommentSenderTest {
   private static class TestSender extends CommentSender {
     TestSender() {
       super(null, null, null, null, null);
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 537ebff..128279f 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
@@ -37,7 +36,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class FromAddressGeneratorProviderTest extends GerritBaseTests {
+public class FromAddressGeneratorProviderTest {
   private Config config;
   private PersonIdent ident;
   private AccountCache accountCache;
@@ -380,7 +379,7 @@
   }
 
   private AccountState makeUser(String name, String email) {
-    final Account.Id userId = new Account.Id(42);
+    final Account.Id userId = Account.id(42);
     final Account account = new Account(userId, TimeUtil.nowTs());
     account.setFullName(name);
     account.setPreferredEmail(email);
diff --git a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java b/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
index 5d6fce7..885f7cd 100644
--- a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class NotificationEmailTest extends GerritBaseTests {
+public class NotificationEmailTest {
 
   @Test
   public void getInstanceAndProjectName_returnsTheRightValue() {
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index a1f318f..032b141 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
@@ -51,9 +52,9 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.AssertableExecutorService;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeAccountCache;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
@@ -63,9 +64,11 @@
 import com.google.inject.util.Providers;
 import java.sql.Timestamp;
 import java.util.TimeZone;
+import java.util.concurrent.ExecutorService;
 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.revwalk.RevWalk;
 import org.junit.After;
@@ -75,7 +78,7 @@
 
 @Ignore
 @RunWith(ConfigSuite.class)
-public abstract class AbstractChangeNotesTest extends GerritBaseTests {
+public abstract class AbstractChangeNotesTest {
   private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
@@ -91,6 +94,7 @@
   protected Project.NameKey project;
   protected RevWalk rw;
   protected TestRepository<InMemoryRepository> tr;
+  protected AssertableExecutorService assertableFanOutExecutor;
 
   @Inject protected IdentifiedUser.GenericFactory userFactory;
 
@@ -110,20 +114,21 @@
     setTimeForTesting();
 
     serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
-    project = new Project.NameKey("test-project");
+    project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
     accountCache = new FakeAccountCache();
-    Account co = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account co = new Account(Account.id(1), TimeUtil.nowTs());
     co.setFullName("Change Owner");
     co.setPreferredEmail("change@owner.com");
     accountCache.put(co);
-    Account ou = new Account(new Account.Id(2), TimeUtil.nowTs());
+    Account ou = new Account(Account.id(2), TimeUtil.nowTs());
     ou.setFullName("Other Account");
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou);
+    assertableFanOutExecutor = new AssertableExecutorService();
 
     injector =
         Guice.createInjector(
@@ -156,6 +161,9 @@
                     .toInstance(serverIdent);
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                bind(ExecutorService.class)
+                    .annotatedWith(FanOutExecutor.class)
+                    .toInstance(assertableFanOutExecutor);
               }
             });
 
@@ -182,7 +190,7 @@
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
     ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
     u.setChangeId(c.getKey().get());
-    u.setBranch(c.getDest().get());
+    u.setBranch(c.getDest().branch());
     u.setWorkInProgress(workInProgress);
     u.commit();
     return c;
@@ -247,7 +255,7 @@
       Timestamp t,
       String message,
       short side,
-      String commitSHA1,
+      ObjectId commitId,
       boolean unresolved) {
     Comment c =
         new Comment(
@@ -260,7 +268,7 @@
             unresolved);
     c.lineNbr = line;
     c.parentUuid = parentUUID;
-    c.revId = commitSHA1;
+    c.setCommitId(commitId);
     c.setRange(range);
     return c;
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
index b4d9738..1141080 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
@@ -31,8 +31,8 @@
   public void keySerializer() throws Exception {
     ChangeNotesCache.Key key =
         ChangeNotesCache.Key.create(
-            new Project.NameKey("project"),
-            new Change.Id(1234),
+            Project.nameKey("project"),
+            Change.id(1234),
             ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
     byte[] serialized = ChangeNotesCache.Key.Serializer.INSTANCE.serialize(key);
     assertThat(ChangeNotesKeyProto.parseFrom(serialized))
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 2931b17..3e54863 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -35,7 +35,6 @@
 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.RevId;
 import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
@@ -50,19 +49,19 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.TypeLiteral;
 import com.google.protobuf.ByteString;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 
-public class ChangeNotesStateTest extends GerritBaseTests {
-  private static final Change.Id ID = new Change.Id(123);
+public class ChangeNotesStateTest {
+  private static final Change.Id ID = Change.id(123);
   private static final ObjectId SHA =
       ObjectId.fromString("1234567812345678123456781234567812345678");
   private static final ByteString SHA_BYTES = ObjectIdConverter.create().toByteString(SHA);
@@ -75,10 +74,10 @@
   public void setUp() throws Exception {
     cols =
         ChangeColumns.builder()
-            .changeKey(new Change.Key(CHANGE_KEY))
+            .changeKey(Change.key(CHANGE_KEY))
             .createdOn(new Timestamp(123456L))
             .lastUpdatedOn(new Timestamp(234567L))
-            .owner(new Account.Id(1000))
+            .owner(Account.id(1000))
             .branch("refs/heads/master")
             .subject("Test change")
             .isPrivate(false)
@@ -98,7 +97,7 @@
         newBuilder()
             .columns(
                 cols.toBuilder()
-                    .changeKey(new Change.Key("Ieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
+                    .changeKey(Change.key("Ieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
                     .build())
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -134,7 +133,7 @@
   @Test
   public void serializeOwner() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().owner(new Account.Id(7777)).build()).build(),
+        newBuilder().columns(cols.toBuilder().owner(Account.id(7777)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -168,7 +167,7 @@
   public void serializeCurrentPatchSetId() throws Exception {
     assertRoundTrip(
         newBuilder()
-            .columns(cols.toBuilder().currentPatchSetId(new PatchSet.Id(ID, 2)).build())
+            .columns(cols.toBuilder().currentPatchSetId(PatchSet.id(ID, 2)).build())
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -243,7 +242,7 @@
   @Test
   public void serializeAssignee() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().assignee(new Account.Id(2000)).build()).build(),
+        newBuilder().columns(cols.toBuilder().assignee(Account.id(2000)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -298,7 +297,7 @@
   @Test
   public void serializeRevertOf() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().revertOf(new Change.Id(999)).build()).build(),
+        newBuilder().columns(cols.toBuilder().revertOf(Change.id(999)).build()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -309,9 +308,7 @@
   @Test
   public void serializePastAssignees() throws Exception {
     assertRoundTrip(
-        newBuilder()
-            .pastAssignees(ImmutableSet.of(new Account.Id(2002), new Account.Id(2001)))
-            .build(),
+        newBuilder().pastAssignees(ImmutableSet.of(Account.id(2002), Account.id(2001))).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -336,25 +333,29 @@
 
   @Test
   public void serializePatchSets() throws Exception {
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(ID, 1));
-    ps1.setUploader(new Account.Id(2000));
-    ps1.setRevision(new RevId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
-    ps1.setCreatedOn(cols.createdOn());
+    PatchSet ps1 =
+        PatchSet.builder()
+            .id(PatchSet.id(ID, 1))
+            .commitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
+            .uploader(Account.id(2000))
+            .createdOn(cols.createdOn())
+            .build();
     ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(ID, 2));
-    ps2.setUploader(new Account.Id(3000));
-    ps2.setRevision(new RevId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
-    ps2.setCreatedOn(cols.lastUpdatedOn());
+    PatchSet ps2 =
+        PatchSet.builder()
+            .id(PatchSet.id(ID, 2))
+            .commitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
+            .uploader(Account.id(3000))
+            .createdOn(cols.lastUpdatedOn())
+            .build();
     ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
     assertRoundTrip(
-        newBuilder()
-            .patchSets(ImmutableMap.of(ps2.getId(), ps2, ps1.getId(), ps1).entrySet())
-            .build(),
+        newBuilder().patchSets(ImmutableMap.of(ps2.id(), ps2, ps1.id(), ps1).entrySet()).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -367,28 +368,31 @@
   @Test
   public void serializeApprovals() throws Exception {
     PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(ID, 1), new Account.Id(2001), new LabelId("Code-Review")),
-            (short) 1,
-            new Timestamp(1212L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create("Code-Review")))
+            .value(1)
+            .granted(new Timestamp(1212L))
+            .build();
     ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a1Bytes.size()).isEqualTo(43);
 
     PatchSetApproval a2 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(
-                new PatchSet.Id(ID, 1), new Account.Id(2002), new LabelId("Verified")),
-            (short) -1,
-            new Timestamp(3434L));
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create("Verified")))
+            .value(-1)
+            .granted(new Timestamp(3434L))
+            .build();
     ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
     assertRoundTrip(
         newBuilder()
-            .approvals(
-                ImmutableListMultimap.of(a2.getPatchSetId(), a2, a1.getPatchSetId(), a1).entries())
+            .approvals(ImmutableListMultimap.of(a2.patchSetId(), a2, a1.patchSetId(), a1).entries())
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -406,11 +410,8 @@
             .reviewers(
                 ReviewerSet.fromTable(
                     ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, new Account.Id(2001), new Timestamp(1212L))
-                        .put(
-                            ReviewerStateInternal.REVIEWER,
-                            new Account.Id(2002),
-                            new Timestamp(3434L))
+                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
+                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -503,11 +504,8 @@
             .pendingReviewers(
                 ReviewerSet.fromTable(
                     ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, new Account.Id(2001), new Timestamp(1212L))
-                        .put(
-                            ReviewerStateInternal.REVIEWER,
-                            new Account.Id(2002),
-                            new Timestamp(3434L))
+                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
+                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -564,9 +562,7 @@
   @Test
   public void serializeAllPastReviewers() throws Exception {
     assertRoundTrip(
-        newBuilder()
-            .allPastReviewers(ImmutableList.of(new Account.Id(2002), new Account.Id(2001)))
-            .build(),
+        newBuilder().allPastReviewers(ImmutableList.of(Account.id(2002), Account.id(2001))).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -584,13 +580,13 @@
                 ImmutableList.of(
                     ReviewerStatusUpdate.create(
                         new Timestamp(1212L),
-                        new Account.Id(1000),
-                        new Account.Id(2002),
+                        Account.id(1000),
+                        Account.id(2002),
                         ReviewerStateInternal.CC),
                     ReviewerStatusUpdate.create(
                         new Timestamp(3434L),
-                        new Account.Id(1000),
-                        new Account.Id(2001),
+                        Account.id(1000),
+                        Account.id(2001),
                         ReviewerStateInternal.REVIEWER)))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -635,19 +631,19 @@
   public void serializeChangeMessages() throws Exception {
     ChangeMessage m1 =
         new ChangeMessage(
-            new ChangeMessage.Key(ID, "uuid1"),
-            new Account.Id(1000),
+            ChangeMessage.key(ID, "uuid1"),
+            Account.id(1000),
             new Timestamp(1212L),
-            new PatchSet.Id(ID, 1));
+            PatchSet.id(ID, 1));
     ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
         new ChangeMessage(
-            new ChangeMessage.Key(ID, "uuid2"),
-            new Account.Id(2000),
+            ChangeMessage.key(ID, "uuid2"),
+            Account.id(2000),
             new Timestamp(3434L),
-            new PatchSet.Id(ID, 2));
+            PatchSet.id(ID, 2));
     ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
@@ -668,31 +664,30 @@
     Comment c1 =
         new Comment(
             new Comment.Key("uuid1", "file1", 1),
-            new Account.Id(1001),
+            Account.id(1001),
             new Timestamp(1212L),
             (short) 1,
             "message 1",
             "serverId",
             false);
-    c1.setRevId(new RevId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
+    c1.setCommitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     String c1Json = Serializer.GSON.toJson(c1);
 
     Comment c2 =
         new Comment(
             new Comment.Key("uuid2", "file2", 2),
-            new Account.Id(1002),
+            Account.id(1002),
             new Timestamp(3434L),
             (short) 2,
             "message 2",
             "serverId",
             true);
-    c2.setRevId(new RevId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
+    c2.setCommitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
     String c2Json = Serializer.GSON.toJson(c2);
 
     assertRoundTrip(
         newBuilder()
-            .publishedComments(
-                ImmutableListMultimap.of(new RevId(c2.revId), c2, new RevId(c1.revId), c1))
+            .publishedComments(ImmutableListMultimap.of(c2.getCommitId(), c2, c1.getCommitId(), c1))
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -704,6 +699,18 @@
   }
 
   @Test
+  public void serializeUpdateCount() throws Exception {
+    assertRoundTrip(
+        newBuilder().updateCount(234).build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .setUpdateCount(234)
+            .build());
+  }
+
+  @Test
   public void changeNotesStateMethods() throws Exception {
     assertThatSerializedClass(ChangeNotesState.class)
         .hasAutoValueMethods(
@@ -732,7 +739,8 @@
                 .put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
                 .put(
                     "publishedComments",
-                    new TypeLiteral<ImmutableListMultimap<RevId, Comment>>() {}.getType())
+                    new TypeLiteral<ImmutableListMultimap<ObjectId, Comment>>() {}.getType())
+                .put("updateCount", int.class)
                 .build());
   }
 
@@ -764,36 +772,37 @@
   @Test
   public void patchSetFields() throws Exception {
     assertThatSerializedClass(PatchSet.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("id", PatchSet.Id.class)
-                .put("revision", RevId.class)
+                .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
                 .put("createdOn", Timestamp.class)
-                .put("groups", String.class)
-                .put("pushCertificate", String.class)
-                .put("description", String.class)
+                .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
+                .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("description", new TypeLiteral<Optional<String>>() {}.getType())
                 .build());
   }
 
   @Test
   public void patchSetApprovalFields() throws Exception {
     assertThatSerializedClass(PatchSetApproval.Key.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("patchSetId", PatchSet.Id.class)
                 .put("accountId", Account.Id.class)
-                .put("categoryId", LabelId.class)
+                .put("labelId", LabelId.class)
                 .build());
     assertThatSerializedClass(PatchSetApproval.class)
-        .hasFields(
+        .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
                 .put("value", short.class)
                 .put("granted", Timestamp.class)
-                .put("tag", String.class)
+                .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
+                .put("toBuilder", PatchSetApproval.Builder.class)
                 .build());
   }
 
@@ -861,7 +870,7 @@
   @Test
   public void changeMessageFields() throws Exception {
     assertThatSerializedClass(ChangeMessage.Key.class)
-        .hasFields(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
+        .hasAutoValueMethods(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
     assertThatSerializedClass(ChangeMessage.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index a6c0224..1e970a1 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -16,15 +16,17 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
 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.REMOVED;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -38,7 +40,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -46,7 +48,6 @@
 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.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
@@ -101,7 +102,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+    assertThat(notes.getCurrentPatchSet().description()).hasValue(description);
 
     description = "new, now more descriptive!";
     update = newUpdate(c, changeOwner);
@@ -109,7 +110,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+    assertThat(notes.getCurrentPatchSet().description()).hasValue(description);
   }
 
   @Test
@@ -131,14 +132,14 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.setTag(tag);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
   }
@@ -162,7 +163,7 @@
 
     ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
     assertThat(approvals).hasSize(1);
-    assertThat(approvals.entries().asList().get(0).getValue().getTag()).isEqualTo(tag2);
+    assertThat(approvals.entries().asList().get(0).getValue().tag()).hasValue(tag2);
   }
 
   @Test
@@ -193,7 +194,7 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.setChangeMessage("coverage verification");
     update.setTag(coverageTag);
@@ -209,10 +210,10 @@
     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);
+    assertThat(approval.tag()).hasValue(integrationTag);
+    assertThat(approval.value()).isEqualTo(-1);
 
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
 
@@ -236,17 +237,17 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).value()).isEqualTo((short) -1);
+    assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(psas.get(0).getGranted());
+    assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(1).label()).isEqualTo("Verified");
+    assertThat(psas.get(1).value()).isEqualTo((short) 1);
+    assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
   }
 
   @Test
@@ -268,18 +269,18 @@
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
-    assertThat(psa1.getPatchSetId()).isEqualTo(ps1);
-    assertThat(psa1.getAccountId().get()).isEqualTo(1);
-    assertThat(psa1.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa1.getValue()).isEqualTo((short) -1);
-    assertThat(psa1.getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psa1.patchSetId()).isEqualTo(ps1);
+    assertThat(psa1.accountId().get()).isEqualTo(1);
+    assertThat(psa1.label()).isEqualTo("Code-Review");
+    assertThat(psa1.value()).isEqualTo((short) -1);
+    assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
-    assertThat(psa2.getPatchSetId()).isEqualTo(ps2);
-    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, 4000)));
+    assertThat(psa2.patchSetId()).isEqualTo(ps2);
+    assertThat(psa2.accountId().get()).isEqualTo(1);
+    assertThat(psa2.label()).isEqualTo("Code-Review");
+    assertThat(psa2.value()).isEqualTo((short) +1);
+    assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
   }
 
   @Test
@@ -292,8 +293,8 @@
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) -1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo((short) -1);
 
     update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
@@ -301,8 +302,8 @@
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.value()).isEqualTo((short) 1);
   }
 
   @Test
@@ -321,17 +322,17 @@
     List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).accountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).value()).isEqualTo((short) -1);
+    assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(2);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(truncate(after(c, 3000)));
+    assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).accountId().get()).isEqualTo(2);
+    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).value()).isEqualTo((short) 1);
+    assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
   }
 
   @Test
@@ -344,9 +345,9 @@
     ChangeNotes notes = newNotes(c);
     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);
+    assertThat(psa.accountId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 1);
 
     update = newUpdate(c, changeOwner);
     update.removeApproval("Not-For-Long");
@@ -356,8 +357,12 @@
     assertThat(notes.getApprovals())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+                psa.patchSetId(),
+                PatchSetApproval.builder()
+                    .key(psa.key())
+                    .value(0)
+                    .granted(update.getWhen())
+                    .build()));
   }
 
   @Test
@@ -370,9 +375,9 @@
     ChangeNotes notes = newNotes(c);
     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);
+    assertThat(psa.accountId()).isEqualTo(otherUserId);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 1);
 
     update = newUpdate(c, changeOwner);
     update.removeApprovalFor(otherUserId, "Not-For-Long");
@@ -382,8 +387,12 @@
     assertThat(notes.getApprovals())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+                psa.patchSetId(),
+                PatchSetApproval.builder()
+                    .key(psa.key())
+                    .value(0)
+                    .granted(update.getWhen())
+                    .build()));
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -392,9 +401,9 @@
 
     notes = newNotes(c);
     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);
+    assertThat(psa.accountId()).isEqualTo(otherUserId);
+    assertThat(psa.label()).isEqualTo("Not-For-Long");
+    assertThat(psa.value()).isEqualTo((short) 2);
   }
 
   @Test
@@ -408,17 +417,17 @@
     ChangeNotes notes = newNotes(c);
     ImmutableList<PatchSetApproval> approvals =
         notes.getApprovals().get(c.currentPatchSetId()).stream()
-            .sorted(comparing(a -> a.getAccountId().get()))
+            .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
 
-    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(0).accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(approvals.get(0).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).value()).isEqualTo((short) 1);
 
-    assertThat(approvals.get(1).getAccountId()).isEqualTo(otherUser.getAccountId());
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo((short) -1);
+    assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo((short) -1);
   }
 
   @Test
@@ -448,12 +457,12 @@
     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();
+    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).value()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo((short) 2);
+    assertThat(approvals.get(1).postSubmit()).isTrue();
   }
 
   @Test
@@ -488,18 +497,18 @@
 
     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();
+    assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).value()).isEqualTo(1);
+    assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).value()).isEqualTo(2);
+    assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
+    assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
+    assertThat(approvals.get(2).label()).isEqualTo("Other-Label");
+    assertThat(approvals.get(2).value()).isEqualTo(2);
+    assertThat(approvals.get(2).postSubmit()).isTrue();
   }
 
   @Test
@@ -516,8 +525,8 @@
         .isEqualTo(
             ReviewerSet.fromTable(
                 ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(REVIEWER, new Account.Id(2), ts)
+                    .put(REVIEWER, Account.id(1), ts)
+                    .put(REVIEWER, Account.id(2), ts)
                     .build()));
   }
 
@@ -535,8 +544,8 @@
         .isEqualTo(
             ReviewerSet.fromTable(
                 ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(CC, new Account.Id(2), ts)
+                    .put(REVIEWER, Account.id(1), ts)
+                    .put(CC, Account.id(2), ts)
                     .build()));
   }
 
@@ -550,7 +559,7 @@
     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)));
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, Account.id(2), ts)));
 
     update = newUpdate(c, otherUser);
     update.putReviewer(otherUser.getAccount().getId(), CC);
@@ -559,7 +568,7 @@
     notes = newNotes(c);
     ts = new Timestamp(update.getWhen().getTime());
     assertThat(notes.getReviewers())
-        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, new Account.Id(2), ts)));
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, Account.id(2), ts)));
   }
 
   @Test
@@ -580,8 +589,8 @@
     ChangeNotes notes = newNotes(c);
     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).accountId()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().getId());
 
     update = newUpdate(c, changeOwner);
     update.removeReviewer(otherUser.getAccount().getId());
@@ -590,7 +599,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).accountId()).isEqualTo(changeOwner.getAccount().getId());
   }
 
   @Test
@@ -819,14 +828,17 @@
 
     // Trying to set another Change-Id fails
     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);
-    update.setChangeId(otherChangeId);
+    ChangeUpdate failingUpdate = newUpdate(c, changeOwner);
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> failingUpdate.setChangeId(otherChangeId));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "The Change-Id was already set to "
+                + c.getKey()
+                + ", so we cannot set this Change-Id: "
+                + otherChangeId);
   }
 
   @Test
@@ -834,7 +846,7 @@
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
-    Branch.NameKey expectedBranch = new Branch.NameKey(project, "refs/heads/master");
+    BranchNameKey expectedBranch = BranchNameKey.create(project, "refs/heads/master");
     assertThat(notes.getChange().getDest()).isEqualTo(expectedBranch);
 
     // An update doesn't affect the branch
@@ -849,7 +861,7 @@
     update.setBranch(otherBranch);
     update.commit();
     assertThat(newNotes(c).getChange().getDest())
-        .isEqualTo(new Branch.NameKey(project, otherBranch));
+        .isEqualTo(BranchNameKey.create(project, otherBranch));
   }
 
   @Test
@@ -956,7 +968,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj, c.getOriginalSubject());
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -968,7 +980,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj, c.getOriginalSubject());
     update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     notes = newNotes(c);
@@ -977,7 +989,7 @@
 
   @Test
   public void commitChangeNotesUnique() throws Exception {
-    // PatchSetId -> RevId must be a one to one mapping
+    // PatchSetId -> ObjectId must be a one to one mapping
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
@@ -990,19 +1002,15 @@
     update.setCommit(rw, commit);
     update.commit();
 
-    try {
-      newNotes(c);
-      fail("Expected IOException");
-    } catch (StorageException e) {
-      assertCause(
-          e,
-          ConfigInvalidException.class,
-          "Multiple revisions parsed for patch set 1:"
-              + " RevId{"
-              + commit.name()
-              + "} and "
-              + ps.getRevision().get());
-    }
+    StorageException e = assertThrows(StorageException.class, () -> newNotes(c));
+    assertCause(
+        e,
+        ConfigInvalidException.class,
+        "Multiple revisions parsed for patch set 1:"
+            + " "
+            + commit.name()
+            + " and "
+            + ps.commitId().name());
   }
 
   @Test
@@ -1012,32 +1020,32 @@
     // ps1 created by newChange()
     ChangeNotes notes = newNotes(c);
     PatchSet ps1 = notes.getCurrentPatchSet();
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.getId());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.id());
     assertThat(notes.getChange().getSubject()).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());
+    assertThat(ps1.id()).isEqualTo(PatchSet.id(c.getId(), 1));
+    assertThat(ps1.uploader()).isEqualTo(changeOwner.getAccountId());
 
     // ps2 by other user
     RevCommit commit = incrementPatchSet(c, otherUser);
     notes = newNotes(c);
     PatchSet ps2 = notes.getCurrentPatchSet();
-    assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
+    assertThat(ps2.id()).isEqualTo(PatchSet.id(c.getId(), 2));
     assertThat(notes.getChange().getSubject()).isEqualTo("PS2");
     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(notes.getChange().getLastUpdatedOn());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.id());
+    assertThat(ps2.commitId()).isNotEqualTo(ps1.commitId());
+    assertThat(ps2.commitId()).isEqualTo(commit);
+    assertThat(ps2.uploader()).isEqualTo(otherUser.getAccountId());
+    assertThat(ps2.createdOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // comment on ps1, current patch set is still ps2
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(ps1.getId());
+    update.setPatchSetId(ps1.id());
     update.setChangeMessage("Comment on old patch set.");
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.id());
   }
 
   @Test
@@ -1065,7 +1073,7 @@
             TimeUtil.nowTs(),
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.commit();
 
@@ -1098,14 +1106,14 @@
     PatchSet.Id psId1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).isEmpty();
+    assertThat(notes.getPatchSets().get(psId1).groups()).isEmpty();
 
     // ps1
     ChangeUpdate update = newUpdate(c, changeOwner);
     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).groups()).containsExactly("a", "b").inOrder();
 
     incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
@@ -1114,8 +1122,8 @@
     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).groups()).containsExactly("d");
+    assertThat(notes.getPatchSets().get(psId1).groups()).containsExactly("a", "b").inOrder();
   }
 
   @Test
@@ -1144,8 +1152,8 @@
     readNote(notes, commit);
 
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
+    assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
     assertThat(notes.getComments()).isEmpty();
 
     // comment on ps2
@@ -1165,15 +1173,15 @@
             ts,
             "Comment",
             (short) 1,
-            commit.name(),
+            commit,
             false));
     update.commit();
 
     notes = newNotes(c);
 
     patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
+    assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
     assertThat(notes.getComments()).isNotEmpty();
   }
 
@@ -1203,13 +1211,13 @@
     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).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(0).label()).isEqualTo("Verified");
+    assertThat(psas.get(0).value()).isEqualTo((short) 1);
 
-    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);
+    assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().getId());
+    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).value()).isEqualTo((short) 2);
   }
 
   @Test
@@ -1235,7 +1243,7 @@
               time1,
               message1,
               (short) 0,
-              "abcd1234abcd1234abcd1234abcd1234abcd1234",
+              ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
               false);
       update1.setPatchSetId(psId);
       update1.putComment(Status.PUBLISHED, comment1);
@@ -1317,11 +1325,11 @@
 
     PatchSetApproval approval1 =
         newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
-    assertThat(approval1.getLabel()).isEqualTo("Verified");
+    assertThat(approval1.label()).isEqualTo("Verified");
 
     PatchSetApproval approval2 =
         newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
-    assertThat(approval2.getLabel()).isEqualTo("Code-Review");
+    assertThat(approval2.label()).isEqualTo("Code-Review");
   }
 
   @Test
@@ -1445,7 +1453,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment =
         newComment(
@@ -1459,14 +1467,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1474,7 +1482,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
     Comment comment =
@@ -1489,14 +1497,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1504,7 +1512,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
     Comment comment =
@@ -1519,14 +1527,14 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1534,7 +1542,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
     Comment comment =
@@ -1549,18 +1557,18 @@
             TimeUtil.nowTs(),
             "message",
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
-  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
+  public void patchLineCommentNotesFormatMultiplePatchSetsSameCommitId() throws Exception {
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
     incrementPatchSet(c);
@@ -1574,7 +1582,7 @@
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
     Timestamp time = TimeUtil.nowTs();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment1 =
         newComment(
@@ -1588,7 +1596,7 @@
             time,
             message1,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
     Comment comment2 =
         newComment(
@@ -1602,7 +1610,7 @@
             time,
             message2,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
     Comment comment3 =
         newComment(
@@ -1616,7 +1624,7 @@
             time,
             message3,
             (short) 0,
-            revId.get(),
+            commitId,
             false);
 
     ChangeUpdate update = newUpdate(c, otherUser);
@@ -1630,9 +1638,9 @@
     assertThat(notes.getComments())
         .isEqualTo(
             ImmutableListMultimap.of(
-                revId, comment1,
-                revId, comment2,
-                revId, comment3));
+                commitId, comment1,
+                commitId, comment2,
+                commitId, comment3));
   }
 
   @Test
@@ -1645,7 +1653,7 @@
     CommentRange range = new CommentRange(1, 1, 2, 1);
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     Comment comment =
         newComment(
@@ -1659,7 +1667,7 @@
             time,
             message,
             (short) 1,
-            revId.get(),
+            commitId,
             false);
     comment.setRealAuthor(changeOwner.getAccountId());
     update.setPatchSetId(psId);
@@ -1668,12 +1676,12 @@
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
-    Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
+    Account account = new Account(Account.id(3), TimeUtil.nowTs());
     account.setFullName("Weird\n\u0002<User>\n");
     account.setPreferredEmail(" we\r\nird@ex>ample<.com");
     accountCache.put(account);
@@ -1698,7 +1706,7 @@
             time,
             "comment",
             (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
@@ -1707,7 +1715,7 @@
     ChangeNotes notes = newNotes(c);
 
     assertThat(notes.getComments())
-        .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
+        .isEqualTo(ImmutableListMultimap.of(comment.getCommitId(), comment));
   }
 
   @Test
@@ -1716,8 +1724,8 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     String messageForBase = "comment for base";
     String messageForPS = "comment for ps";
     CommentRange range = new CommentRange(1, 1, 2, 1);
@@ -1736,7 +1744,7 @@
             now,
             messageForBase,
             (short) 0,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, commentForBase);
@@ -1755,7 +1763,7 @@
             now,
             messageForPS,
             (short) 1,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, commentForPS);
@@ -1764,8 +1772,8 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), commentForBase,
-                new RevId(rev2), commentForPS));
+                commitId1, commentForBase,
+                commitId2, commentForPS));
   }
 
   @Test
@@ -1773,7 +1781,7 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename = "filename";
@@ -1794,7 +1802,7 @@
             timeForComment1,
             "comment 1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment1);
@@ -1813,7 +1821,7 @@
             timeForComment2,
             "comment 2",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment2);
@@ -1822,8 +1830,8 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
   }
 
@@ -1831,7 +1839,7 @@
   public void patchLineCommentMultipleOnePatchsetMultipleFiles() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename1 = "filename1";
@@ -1852,7 +1860,7 @@
             now,
             "comment 1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment1);
@@ -1871,7 +1879,7 @@
             now,
             "comment 2",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment2);
@@ -1880,8 +1888,8 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
   }
 
@@ -1889,8 +1897,8 @@
   public void patchLineCommentMultiplePatchsets() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -1910,7 +1918,7 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(ps1);
     update.putComment(Status.PUBLISHED, comment1);
@@ -1933,7 +1941,7 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(ps2);
     update.putComment(Status.PUBLISHED, comment2);
@@ -1942,15 +1950,15 @@
     assertThat(newNotes(c).getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), comment1,
-                new RevId(rev2), comment2));
+                commitId1, comment1,
+                commitId2, comment2));
   }
 
   @Test
   public void patchLineCommentSingleDraftToPublished() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -1970,7 +1978,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(ps1);
     update.putComment(Status.DRAFT, comment1);
@@ -1978,7 +1986,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
     assertThat(notes.getComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
@@ -1989,7 +1997,7 @@
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
     assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
   @Test
@@ -1997,7 +2005,7 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
@@ -2020,7 +2028,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     Comment comment2 =
         newComment(
@@ -2034,7 +2042,7 @@
             now,
             "other on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.DRAFT, comment1);
     update.putComment(Status.DRAFT, comment2);
@@ -2044,8 +2052,8 @@
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
+                commitId, comment1,
+                commitId, comment2))
         .inOrder();
     assertThat(notes.getComments()).isEmpty();
 
@@ -2057,9 +2065,9 @@
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment2));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment2));
     assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
   @Test
@@ -2067,8 +2075,8 @@
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
@@ -2090,7 +2098,7 @@
             now,
             "comment on base",
             (short) 0,
-            rev1,
+            commitId1,
             false);
     Comment psComment =
         newComment(
@@ -2104,7 +2112,7 @@
             now,
             "comment on ps",
             (short) 1,
-            rev2,
+            commitId2,
             false);
 
     update.putComment(Status.DRAFT, baseComment);
@@ -2115,8 +2123,8 @@
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
+                commitId1, baseComment,
+                commitId2, psComment));
     assertThat(notes.getComments()).isEmpty();
 
     // Publish both comments.
@@ -2132,16 +2140,15 @@
     assertThat(notes.getComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
+                commitId1, baseComment,
+                commitId2, psComment));
   }
 
   @Test
   public void patchLineCommentsDeleteAllDrafts() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    ObjectId objId = ObjectId.fromString(rev);
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id psId = c.currentPatchSetId();
     String filename = "filename";
@@ -2161,7 +2168,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.DRAFT, comment);
@@ -2169,7 +2176,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
-    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
+    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(commitId)).isTrue();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
@@ -2185,10 +2192,8 @@
   public void patchLineCommentsDeleteAllDraftsForOneRevision() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    ObjectId objId1 = ObjectId.fromString(rev1);
-    ObjectId objId2 = ObjectId.fromString(rev2);
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2208,7 +2213,7 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     update.setPatchSetId(ps1);
     update.putComment(Status.DRAFT, comment1);
@@ -2231,7 +2236,7 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.setPatchSetId(ps2);
     update.putComment(Status.DRAFT, comment2);
@@ -2248,15 +2253,15 @@
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
     NoteMap noteMap = notes.getDraftCommentNotes().getNoteMap();
-    assertThat(noteMap.contains(objId1)).isTrue();
-    assertThat(noteMap.contains(objId2)).isFalse();
+    assertThat(noteMap.contains(commitId1)).isTrue();
+    assertThat(noteMap.contains(commitId2)).isFalse();
   }
 
   @Test
   public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2276,7 +2281,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
@@ -2289,7 +2294,7 @@
   @Test
   public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef() throws Exception {
     Change c = newChange();
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2309,7 +2314,7 @@
             now,
             "draft comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.DRAFT, draft);
     update.commit();
@@ -2331,7 +2336,7 @@
             now,
             "comment on ps1",
             side,
-            rev,
+            commitId,
             false);
     update.putComment(Status.PUBLISHED, pub);
     update.commit();
@@ -2344,7 +2349,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
@@ -2361,14 +2366,14 @@
             now,
             messageForBase,
             (short) 0,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -2376,7 +2381,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
@@ -2393,22 +2398,22 @@
             now,
             messageForBase,
             (short) 0,
-            rev,
+            commitId,
             false);
     update.setPatchSetId(psId);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
   public void putCommentsForMultipleRevisions() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId2 = ObjectId.fromString("abcd4567abcd4567abcd4567abcd4567abcd4567");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     String filename = "filename1";
@@ -2432,7 +2437,7 @@
             now,
             "comment on ps1",
             side,
-            rev1,
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2446,7 +2451,7 @@
             now,
             "comment on ps2",
             side,
-            rev2,
+            commitId2,
             false);
     update.putComment(Status.DRAFT, comment1);
     update.putComment(Status.DRAFT, comment2);
@@ -2470,7 +2475,7 @@
   @Test
   public void publishSubsetOfCommentsOnRevision() throws Exception {
     Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     short side = (short) 1;
@@ -2490,7 +2495,7 @@
             now,
             "comment1",
             side,
-            rev1.get(),
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2504,14 +2509,15 @@
             now,
             "comment2",
             side,
-            rev1.get(),
+            commitId1,
             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(commitId1))
+        .containsExactly(comment1, comment2);
     assertThat(notes.getComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
@@ -2520,8 +2526,8 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
   }
 
   @Test
@@ -2535,15 +2541,15 @@
     assertThat(msg.getMessage()).isEqualTo("A message.");
     assertThat(msg.getAuthor()).isNull();
 
-    update = newUpdate(c, internalUser);
-    exception.expect(IllegalStateException.class);
-    update.putApproval("Code-Review", (short) 1);
+    ChangeUpdate failingUpdate = newUpdate(c, internalUser);
+    assertThrows(
+        IllegalStateException.class, () -> failingUpdate.putApproval("Code-Review", (short) 1));
   }
 
   @Test
   public void filterOutAndFixUpZombieDraftComments() throws Exception {
     Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 1, 2, 1);
     PatchSet.Id ps1 = c.currentPatchSetId();
     short side = (short) 1;
@@ -2562,7 +2568,7 @@
             now,
             "comment on ps1",
             side,
-            rev1.get(),
+            commitId1,
             false);
     Comment comment2 =
         newComment(
@@ -2576,7 +2582,7 @@
             now,
             "another comment",
             side,
-            rev1.get(),
+            commitId1,
             false);
     update.putComment(Status.DRAFT, comment1);
     update.putComment(Status.DRAFT, comment2);
@@ -2604,12 +2610,12 @@
 
     // Looking at drafts directly shows the zombie comment.
     DraftCommentNotes draftNotes = draftNotesFactory.create(c.getId(), otherUserId);
-    assertThat(draftNotes.load().getComments().get(rev1)).containsExactly(comment1, comment2);
+    assertThat(draftNotes.load().getComments().get(commitId1)).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(commitId1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
@@ -2624,7 +2630,7 @@
   public void updateCommentsInSequentialUpdates() throws Exception {
     Change c = newChange();
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
     Comment comment1 =
@@ -2639,7 +2645,7 @@
             new Timestamp(update1.getWhen().getTime()),
             "comment 1",
             (short) 1,
-            rev,
+            commitId,
             false);
     update1.putComment(Status.PUBLISHED, comment1);
 
@@ -2656,7 +2662,7 @@
             new Timestamp(update2.getWhen().getTime()),
             "comment 2",
             (short) 1,
-            rev,
+            commitId,
             false);
     update2.putComment(Status.PUBLISHED, comment2);
 
@@ -2667,7 +2673,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<Comment> comments = notes.getComments().get(new RevId(rev));
+    List<Comment> comments = notes.getComments().get(commitId);
     assertThat(comments).hasSize(2);
     assertThat(comments.get(0).message).isEqualTo("comment 1");
     assertThat(comments.get(1).message).isEqualTo("comment 2");
@@ -2697,7 +2703,7 @@
     int numComments = notes.getComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), c.currentPatchSetId().get() + 1));
+    update.setPatchSetId(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);
@@ -2713,7 +2719,7 @@
             new Timestamp(update.getWhen().getTime()),
             "comment",
             (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.putComment(Status.PUBLISHED, comment);
     update.commit();
@@ -2734,7 +2740,7 @@
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), 1));
     update.setCurrentPatchSet();
     update.commit();
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
@@ -2751,7 +2757,7 @@
 
     // Delete PS1, PS2 becomes current.
     update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetId(PatchSet.id(c.getId(), 1));
     update.setPatchSetState(PatchSetState.DELETED);
     update.commit();
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
@@ -3006,9 +3012,9 @@
   public void setRevertOfToCurrentChangeFails() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("A change cannot revert itself");
-    update.setRevertOf(c.getId().get());
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> update.setRevertOf(c.getId().get()));
+    assertThat(thrown).hasMessageThat().contains("A change cannot revert itself");
   }
 
   @Test
@@ -3016,9 +3022,26 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setRevertOf(newChange().getId().get());
-    exception.expect(StorageException.class);
-    exception.expectMessage("Given ChangeUpdate is only allowed on initial commit");
+    StorageException thrown = assertThrows(StorageException.class, () -> update.commit());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Given ChangeUpdate is only allowed on initial commit");
+  }
+
+  @Test
+  public void updateCount() throws Exception {
+    Change c = newChange();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(1);
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) -1);
     update.commit();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(2);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+    assertThat(newNotes(c).getUpdateCount()).isEqualTo(3);
   }
 
   private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
@@ -3042,11 +3065,11 @@
         break;
       }
     }
-    assertThat(cause)
-        .named(
+    assertWithMessage(
             expectedClass.getSimpleName()
                 + " in causal chain of:\n"
                 + Throwables.getStackTraceAsString(e))
+        .that(cause)
         .isNotNull();
     assertThat(cause.getMessage()).isEqualTo(expectedMsg);
   }
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 4dd2005..02f187d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -18,18 +18,18 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.testing.GerritBaseTests;
 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.eclipse.jgit.lib.ObjectId;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-public class CommentTimestampAdapterTest extends GerritBaseTests {
+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";
 
@@ -153,14 +153,14 @@
     Comment c =
         new Comment(
             new Comment.Key("uuid", "filename", 1),
-            new Account.Id(100),
+            Account.id(100),
             NON_DST_TS,
             (short) 0,
             "message",
             "serverId",
             false);
     c.lineNbr = 1;
-    c.revId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    c.setCommitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
 
     String json = gson.toJson(c);
     assertThat(json).contains("\"writtenOn\": \"" + NON_DST_STR_TRUNC + "\",");
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 5552572..24d098e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -199,7 +199,7 @@
 
   @Test
   public void anonymousUser() throws Exception {
-    Account anon = new Account(new Account.Id(3), TimeUtil.nowTs());
+    Account anon = new Account(Account.id(3), TimeUtil.nowTs());
     accountCache.put(anon);
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, userFactory.create(anon.getId()));
@@ -311,7 +311,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(), c.getOriginalSubject());
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     assertBodyEquals(
@@ -332,7 +332,7 @@
     c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(), c.getOriginalSubject());
     update = newUpdateForNewChange(c, changeOwner);
     update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
+    update.setBranch(c.getDest().branch());
     update.commit();
 
     assertBodyEquals(
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
new file mode 100644
index 0000000..ac13037
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Change;
+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.server.util.time.TimeUtil;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class DraftCommentNotesTest extends AbstractChangeNotesTest {
+
+  @Test
+  public void createAndPublishCommentInOneAction_runsDraftOperationAsynchronously()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.commit();
+
+    assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(1);
+  }
+
+  @Test
+  public void createAndPublishComment_runsPublishDraftOperationAsynchronously() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Status.DRAFT, comment(c.currentPatchSetId()));
+    update.commit();
+    assertThat(newNotes(c).getDraftComments(otherUserId)).hasSize(1);
+    assertableFanOutExecutor.assertInteractions(0);
+
+    update = newUpdate(c, otherUser);
+    update.putComment(Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.commit();
+
+    assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(1);
+  }
+
+  @Test
+  public void createAndDeleteDraftComment_runsDraftOperationSynchronously() throws Exception {
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(Status.DRAFT, comment(c.currentPatchSetId()));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
+    assertableFanOutExecutor.assertInteractions(0);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.deleteComment(comment(c.currentPatchSetId()));
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
+    assertableFanOutExecutor.assertInteractions(0);
+  }
+
+  private Comment comment(PatchSet.Id psId) {
+    return newComment(
+        psId,
+        "filename",
+        "uuid",
+        null,
+        0,
+        otherUser,
+        null,
+        TimeUtil.nowTs(),
+        "comment",
+        (short) 0,
+        ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
+        false);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
index 1cbe61d..7ddc86f 100644
--- a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -44,7 +44,7 @@
 
   @Before
   public void setUp() throws Exception {
-    projectName = new Project.NameKey("repo");
+    projectName = Project.nameKey("repo");
     repo = new InMemoryRepository(new DfsRepositoryDescription(projectName.get()));
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index 263873d..b7cd053 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 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.github.rholder.retry.Retryer;
 import com.github.rholder.retry.RetryerBuilder;
@@ -27,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.util.concurrent.ExecutionException;
@@ -43,7 +43,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class RepoSequenceTest extends GerritBaseTests {
+public class RepoSequenceTest {
   // Don't sleep in tests.
   private static final Retryer<RefUpdate> RETRYER =
       RepoSequence.retryerBuilder().withBlockStrategy(t -> {}).build();
@@ -54,7 +54,7 @@
   @Before
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
-    project = new Project.NameKey("project");
+    project = Project.nameKey("project");
     repoManager.createRepository(project);
   }
 
@@ -66,13 +66,13 @@
       RepoSequence s = newSequence(name, 1, batchSize);
       for (int i = 1; i <= max; i++) {
         try {
-          assertThat(s.next()).named("i=" + i + " for " + name).isEqualTo(i);
+          assertWithMessage("i=" + i + " for " + name).that(s.next()).isEqualTo(i);
         } catch (StorageException e) {
           throw new AssertionError("failed batchSize=" + batchSize + ", i=" + i, e);
         }
       }
-      assertThat(s.acquireCount)
-          .named("acquireCount for " + name)
+      assertWithMessage("acquireCount for " + name)
+          .that(s.acquireCount)
           .isEqualTo(divCeil(max, batchSize));
     }
   }
@@ -168,9 +168,11 @@
   @Test
   public void failOnInvalidValue() throws Exception {
     ObjectId id = writeBlob("id", "not a number");
-    exception.expect(StorageException.class);
-    exception.expectMessage("invalid value in refs/sequences/id blob at " + id.name());
-    newSequence("id", 1, 3).next();
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> newSequence("id", 1, 3).next());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("invalid value in refs/sequences/id blob at " + id.name());
   }
 
   @Test
@@ -178,13 +180,10 @@
     try (Repository repo = repoManager.openRepository(project)) {
       TestRepository<Repository> tr = new TestRepository<>(repo);
       tr.branch(RefNames.REFS_SEQUENCES + "id").commit().create();
-      try {
-        newSequence("id", 1, 3).next();
-        fail();
-      } catch (StorageException e) {
-        assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
-        assertThat(e.getCause().getCause()).isInstanceOf(IncorrectObjectTypeException.class);
-      }
+      StorageException e =
+          assertThrows(StorageException.class, () -> newSequence("id", 1, 3).next());
+      assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
+      assertThat(e.getCause().getCause()).isInstanceOf(IncorrectObjectTypeException.class);
     }
   }
 
@@ -200,9 +199,10 @@
             RetryerBuilder.<RefUpdate>newBuilder()
                 .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                 .build());
-    exception.expect(StorageException.class);
-    exception.expectMessage("Failed to update refs/sequences/id: LOCK_FAILURE");
-    s.next();
+    StorageException thrown = assertThrows(StorageException.class, () -> s.next());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to update refs/sequences/id: LOCK_FAILURE");
   }
 
   @Test
@@ -335,9 +335,10 @@
             RetryerBuilder.<RefUpdate>newBuilder()
                 .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                 .build());
-    exception.expect(StorageException.class);
-    exception.expectMessage("Failed to update refs/sequences/id: LOCK_FAILURE");
-    s.increaseTo(2);
+    StorageException thrown = assertThrows(StorageException.class, () -> s.increaseTo(2));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Failed to update refs/sequences/id: LOCK_FAILURE");
   }
 
   private RepoSequence newSequence(String name, int start, int batchSize) {
diff --git a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index 0cc4b00..f544550 100644
--- a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.testing.GerritBaseTests;
 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;
 
-public class IntraLineLoaderTest extends GerritBaseTests {
+public class IntraLineLoaderTest {
 
   @Test
   public void rewriteAtStartOfLineIsRecognized() throws Exception {
@@ -88,22 +88,30 @@
   // 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)
+  @Test
   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);
+    assertThrows(
+        AssertionError.class,
+        () -> {
+          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);
+        });
   }
 
   // TODO: expected failure
   // the current code does not work on the first line
-  @Test(expected = AssertionError.class)
+  @Test
   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);
+    assertThrows(
+        AssertionError.class,
+        () -> {
+          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);
+        });
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java b/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
index 4ed5f4b..81f03af 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
@@ -20,10 +20,9 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class PatchListEntryTest extends GerritBaseTests {
+public class PatchListEntryTest {
   @Test
   public void empty1() {
     final String name = "empty-file";
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
index ccdd040..6fbafb6 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
@@ -26,7 +25,7 @@
 import java.util.Arrays;
 import org.junit.Test;
 
-public class PatchListTest extends GerritBaseTests {
+public class PatchListTest {
   @Test
   public void fileOrder() {
     String[] names = {
diff --git a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
index ff9ac41..305e81b 100644
--- a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
+++ b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
@@ -18,10 +18,9 @@
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.refPermission;
 
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class DefaultPermissionsMappingTest extends GerritBaseTests {
+public class DefaultPermissionsMappingTest {
   @Test
   public void stringToRefPermission() {
     assertThat(refPermission("doesnotexist")).isEmpty();
diff --git a/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java b/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
index f40c3bc..7aa73a7 100644
--- a/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
+++ b/javatests/com/google/gerrit/server/permissions/PluginPermissionsUtilTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
 
 import com.google.common.collect.ImmutableList;
@@ -30,8 +30,8 @@
         ImmutableList.of("plugin-foo-a", "plugin-foo-a-b");
 
     for (String permission : validPluginPermissions) {
-      assertThat(isValidPluginPermission(permission))
-          .named("valid plugin permission: %s", permission)
+      assertWithMessage("valid plugin permission: %s", permission)
+          .that(isValidPluginPermission(permission))
           .isTrue();
     }
   }
@@ -48,8 +48,8 @@
             "plugin-foo-a1");
 
     for (String permission : invalidPluginPermissions) {
-      assertThat(isValidPluginPermission(permission))
-          .named("invalid plugin permission: %s", permission)
+      assertWithMessage("invalid plugin permission: %s", permission)
+          .that(isValidPluginPermission(permission))
           .isFalse();
     }
   }
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 5f08875..761d682 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
 import static com.google.gerrit.common.data.Permission.LABEL;
 import static com.google.gerrit.common.data.Permission.OWNER;
@@ -31,6 +32,7 @@
 import static com.google.gerrit.server.project.testing.Util.block;
 import static com.google.gerrit.server.project.testing.Util.deny;
 import static com.google.gerrit.server.project.testing.Util.doNotInherit;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.InMemoryRepositoryManager.newRepository;
 
 import com.google.common.cache.Cache;
@@ -63,7 +65,6 @@
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.Guice;
@@ -84,107 +85,107 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class RefControlTest extends GerritBaseTests {
+public class RefControlTest {
   private void assertAdminsAreOwnersAndDevsAreNot() {
     ProjectControl uBlah = user(local, DEVS);
     ProjectControl uAdmin = user(local, DEVS, ADMIN);
 
-    assertThat(uBlah.isOwner()).named("not owner").isFalse();
-    assertThat(uAdmin.isOwner()).named("is owner").isTrue();
+    assertWithMessage("not owner").that(uBlah.isOwner()).isFalse();
+    assertWithMessage("is owner").that(uAdmin.isOwner()).isTrue();
   }
 
   private void assertOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("OWN " + ref).isTrue();
+    assertWithMessage("OWN " + ref).that(u.controlForRef(ref).isOwner()).isTrue();
   }
 
   private void assertNotOwner(ProjectControl u) {
-    assertThat(u.isOwner()).named("not owner").isFalse();
+    assertWithMessage("not owner").that(u.isOwner()).isFalse();
   }
 
   private void assertNotOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
+    assertWithMessage("NOT OWN " + ref).that(u.controlForRef(ref).isOwner()).isFalse();
   }
 
   private void assertCanAccess(ProjectControl u) {
     boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("can access").isTrue();
+    assertWithMessage("can access").that(access).isTrue();
   }
 
   private void assertAccessDenied(ProjectControl u) {
     boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("cannot access").isFalse();
+    assertWithMessage("cannot access").that(access).isFalse();
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("can read " + ref).isTrue();
+    assertWithMessage("can read " + ref).that(u.controlForRef(ref).isVisible()).isTrue();
   }
 
   private void assertCannotRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("cannot read " + ref).isFalse();
+    assertWithMessage("cannot read " + ref).that(u.controlForRef(ref).isVisible()).isFalse();
   }
 
   private void assertCanSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isTrue();
+    assertWithMessage("can submit " + ref).that(u.controlForRef(ref).canSubmit(false)).isTrue();
   }
 
   private void assertCannotSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isFalse();
+    assertWithMessage("can submit " + ref).that(u.controlForRef(ref).canSubmit(false)).isFalse();
   }
 
   private void assertCanUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isTrue();
+    assertWithMessage("can upload").that(u.canPushToAtLeastOneRef()).isTrue();
   }
 
   private void assertCreateChange(String ref, ProjectControl u) {
     boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("can create change " + ref).isTrue();
+    assertWithMessage("can create change " + ref).that(create).isTrue();
   }
 
   private void assertCannotUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isFalse();
+    assertWithMessage("cannot upload").that(u.canPushToAtLeastOneRef()).isFalse();
   }
 
   private void assertCannotCreateChange(String ref, ProjectControl u) {
     boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("cannot create change " + ref).isFalse();
+    assertWithMessage("cannot create change " + ref).that(create).isFalse();
   }
 
   private void assertCanUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("can update " + ref).isTrue();
+    assertWithMessage("can update " + ref).that(update).isTrue();
   }
 
   private void assertCannotUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("cannot update " + ref).isFalse();
+    assertWithMessage("cannot update " + ref).that(update).isFalse();
   }
 
   private void assertCanForceUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("can force push " + ref).isTrue();
+    assertWithMessage("can force push " + ref).that(update).isTrue();
   }
 
   private void assertCannotForceUpdate(String ref, ProjectControl u) {
     boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("cannot force push " + ref).isFalse();
+    assertWithMessage("cannot force push " + ref).that(update).isFalse();
   }
 
   private void assertCanVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("can vote " + score).isTrue();
+    assertWithMessage("can vote " + score).that(range.contains(score)).isTrue();
   }
 
   private void assertCannotVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("cannot vote " + score).isFalse();
+    assertWithMessage("cannot vote " + score).that(range.contains(score)).isFalse();
   }
 
   private final AllProjectsName allProjectsName =
       new AllProjectsName(AllProjectsNameProvider.DEFAULT);
   private final AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-  private final AccountGroup.UUID fixers = new AccountGroup.UUID("test.fixers");
+  private final AccountGroup.UUID fixers = AccountGroup.uuid("test.fixers");
   private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
-  private Project.NameKey localKey = new Project.NameKey("local");
+  private Project.NameKey localKey = Project.nameKey("local");
   private ProjectConfig local;
-  private Project.NameKey parentKey = new Project.NameKey("parent");
+  private Project.NameKey parentKey = Project.nameKey("parent");
   private ProjectConfig parent;
   private InMemoryRepositoryManager repoManager;
   private ProjectCache projectCache;
@@ -269,7 +270,7 @@
     try {
       Repository repo = repoManager.createRepository(allProjectsName);
       ProjectConfig allProjects =
-          projectConfigFactory.create(new Project.NameKey(allProjectsName.get()));
+          projectConfigFactory.create(Project.nameKey(allProjectsName.get()));
       allProjects.load(repo);
       LabelType cr = Util.codeReview();
       allProjects.getLabelSections().put(cr.getName(), cr);
@@ -408,11 +409,11 @@
     ProjectControl u = user(local);
     ProjectControl a = user(local, "a", ADMIN);
 
-    assertThat(a.controlForRef("refs/drafts/master").canPerform(PUSH))
-        .named("push is allowed")
+    assertWithMessage("push is allowed")
+        .that(a.controlForRef("refs/drafts/master").canPerform(PUSH))
         .isTrue();
-    assertThat(u.controlForRef("refs/drafts/master").canPerform(PUSH))
-        .named("push is not allowed")
+    assertWithMessage("push is not allowed")
+        .that(u.controlForRef("refs/drafts/master").canPerform(PUSH))
         .isFalse();
   }
 
@@ -599,8 +600,8 @@
     allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local);
-    assertThat(u.controlForRef("refs/heads/master").canPerform(SUBMIT))
-        .named("submit is allowed")
+    assertWithMessage("submit is allowed")
+        .that(u.controlForRef("refs/heads/master").canPerform(SUBMIT))
         .isTrue();
   }
 
@@ -763,8 +764,8 @@
     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")
+    assertWithMessage("u can edit topic name")
+        .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isTrue();
   }
 
@@ -774,8 +775,8 @@
     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")
+    assertWithMessage("u can't edit topic name")
+        .that(u.controlForRef("refs/heads/master").canForceEditTopicName())
         .isFalse();
   }
 
@@ -914,14 +915,16 @@
     RefPattern.validate("refs/heads/review/${username}/*");
   }
 
-  @Test(expected = InvalidNameException.class)
+  @Test
   public void testValidateBadRefPatternDoubleCaret() throws Exception {
-    RefPattern.validate("^^refs/*");
+    assertThrows(InvalidNameException.class, () -> RefPattern.validate("^^refs/*"));
   }
 
-  @Test(expected = InvalidNameException.class)
+  @Test
   public void testValidateBadRefPatternDanglingCharacter() throws Exception {
-    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
+    assertThrows(
+        InvalidNameException.class,
+        () -> RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index cf6d50f..8f6119a 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
@@ -49,7 +48,7 @@
 import org.junit.Test;
 
 /** Unit tests for {@link CommitsCollection}. */
-public class CommitsCollectionTest extends GerritBaseTests {
+public class CommitsCollectionTest {
   @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
 
   @Inject private AccountManager accountManager;
@@ -70,7 +69,7 @@
     Account.Id user = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     testEnvironment.setApiUser(user);
 
-    Project.NameKey name = new Project.NameKey("project");
+    Project.NameKey name = Project.nameKey("project");
     InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
     project = projectConfigFactory.create(name);
     project.load(inMemoryRepo);
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 08aca9f..5ccefa0 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
@@ -37,8 +36,8 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupListTest extends GerritBaseTests {
-  private static final Project.NameKey PROJECT = new Project.NameKey("project");
+public class GroupListTest {
+  private static final Project.NameKey PROJECT = Project.nameKey("project");
   private static final String TEXT =
       "# UUID                                  \tGroup Name\n"
           + "#\n"
@@ -56,7 +55,7 @@
 
   @Test
   public void byUUID() throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+    AccountGroup.UUID uuid = AccountGroup.uuid("d96b998f8a66ff433af50befb975d0e2bb6e0999");
 
     GroupReference groupReference = groupList.byUUID(uuid);
 
@@ -66,7 +65,7 @@
 
   @Test
   public void put() {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("abc");
+    AccountGroup.UUID uuid = AccountGroup.uuid("abc");
     GroupReference groupReference = new GroupReference(uuid, "Hutzliputz");
 
     groupList.put(uuid, groupReference);
@@ -81,7 +80,7 @@
     Collection<GroupReference> result = groupList.references();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID uuid = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID uuid = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     GroupReference expected = new GroupReference(uuid, "Administrators");
 
     assertTrue(result.contains(expected));
@@ -92,7 +91,7 @@
     Set<AccountGroup.UUID> result = groupList.uuids();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID expected = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID expected = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     assertTrue(result.contains(expected));
   }
 
@@ -108,11 +107,11 @@
 
   @Test
   public void retainAll() throws Exception {
-    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+    AccountGroup.UUID uuid = AccountGroup.uuid("d96b998f8a66ff433af50befb975d0e2bb6e0999");
     groupList.retainUUIDs(Collections.singleton(uuid));
 
     assertNotNull(groupList.byUUID(uuid));
-    assertNull(groupList.byUUID(new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
+    assertNull(groupList.byUUID(AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 3436153..15c757d 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -37,7 +37,6 @@
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.testing.Util;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -62,7 +61,7 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 
-public class ProjectConfigTest extends GerritBaseTests {
+public class ProjectConfigTest {
   private static final String LABEL_SCORES_CONFIG =
       "  copyMinScore = "
           + !LabelType.DEF_COPY_MIN_SCORE
@@ -88,8 +87,8 @@
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private final GroupReference developers =
-      new GroupReference(new AccountGroup.UUID("X"), "Developers");
-  private final GroupReference staff = new GroupReference(new AccountGroup.UUID("Y"), "Staff");
+      new GroupReference(AccountGroup.uuid("X"), "Developers");
+  private final GroupReference staff = new GroupReference(AccountGroup.uuid("Y"), "Staff");
 
   private SitePaths sitePaths;
   private ProjectConfig.Factory factory;
@@ -422,7 +421,7 @@
 
   @Test
   public void readUnexistingPluginConfig() throws Exception {
-    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db);
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).isEmpty();
@@ -625,7 +624,7 @@
 
   @Test
   public void readOtherProjectIgnoresAllProjectsBaseConfig() throws Exception {
-    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db);
     assertThat(cfg.getProject().getBooleanConfig(REQUIRE_CHANGE_ID))
         .isEqualTo(InheritableBoolean.INHERIT);
@@ -648,7 +647,7 @@
   }
 
   private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
-    ProjectConfig cfg = factory.create(new Project.NameKey("test"));
+    ProjectConfig cfg = factory.create(Project.nameKey("test"));
     cfg.load(db, rev);
     return cfg;
   }
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index a2b2866..2a7523b 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.query.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
@@ -79,6 +81,7 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -93,10 +96,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryAccountsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -253,7 +259,7 @@
     addEmails(user1, secondaryEmail);
 
     AccountInfo user2 = newAccount("user");
-    requestContext.setContext(newRequestContext(new Account.Id(user2._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
 
     if (getSchemaVersion() < 5) {
       assertMissingField(AccountField.PREFERRED_EMAIL);
@@ -340,7 +346,7 @@
     AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
 
     AccountInfo user3 = newAccount("user");
-    requestContext.setContext(newRequestContext(new Account.Id(user3._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
 
     assertQuery("notexisting");
     assertQuery("Not Existing");
@@ -575,13 +581,13 @@
     String[] secondaryEmails = new String[] {"dfg@example.com", "hij@example.com"};
     addEmails(otherUser, secondaryEmails);
 
-    requestContext.setContext(newRequestContext(new Account.Id(user._accountId)));
+    requestContext.setContext(newRequestContext(Account.id(user._accountId)));
 
     List<AccountInfo> result = newQuery(otherUser.username).withSuggest(true).get();
     assertThat(result.get(0).secondaryEmails).isNull();
-
-    exception.expect(AuthException.class);
-    newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get();
+    assertThrows(
+        AuthException.class,
+        () -> newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get());
   }
 
   @Test
@@ -600,7 +606,7 @@
     AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
 
     // update account without reindex so that account index is stale
-    Account.Id accountId = new Account.Id(user1._accountId);
+    Account.Id accountId = Account.id(user1._accountId);
     String newName = "Test User";
     try (Repository repo = repoManager.openRepository(allUsers)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repo);
@@ -629,7 +635,7 @@
         indexes
             .getSearchIndex()
             .getRaw(
-                new Account.Id(userInfo._accountId),
+                Account.id(userInfo._accountId),
                 QueryOptions.create(
                     IndexConfig.createDefault(),
                     0,
@@ -695,7 +701,7 @@
     in.name = name;
     in.createEmptyCommit = true;
     gApi.projects().create(in);
-    return new Project.NameKey(name);
+    return Project.nameKey(name);
   }
 
   protected void blockRead(Project.NameKey project, GroupInfo group) throws RestApiException {
@@ -750,7 +756,7 @@
       return null;
     }
 
-    String suffix = getSanitizedMethodName();
+    String suffix = testName.getSanitizedMethodName();
     if (name.contains("@")) {
       return name + "." + suffix;
     }
@@ -777,7 +783,7 @@
   }
 
   private void addEmails(AccountInfo account, String... emails) throws Exception {
-    Account.Id id = new Account.Id(account._accountId);
+    Account.Id id = Account.id(account._accountId);
     for (String email : emails) {
       accountManager.link(id, AuthRequest.forEmail(email));
     }
@@ -802,8 +808,8 @@
       throws Exception {
     List<AccountInfo> result = query.get();
     Iterable<Integer> ids = ids(result);
-    assertThat(ids)
-        .named(format(query, result, accounts))
+    assertWithMessage(format(query, result, accounts))
+        .that(ids)
         .containsExactlyElementsIn(ids(accounts))
         .inOrder();
     return result;
@@ -860,8 +866,8 @@
   }
 
   protected void assertMissingField(FieldDef<AccountState, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
+        .that(getSchema().hasField(field))
         .isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 2f1d93a..65c6e3f 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 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.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
@@ -24,6 +25,7 @@
 import static com.google.gerrit.server.project.testing.Util.category;
 import static com.google.gerrit.server.project.testing.Util.value;
 import static com.google.gerrit.server.project.testing.Util.verified;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -73,7 +75,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -544,7 +546,7 @@
   public void restorePendingReviewers() throws Exception {
     assume().that(getSchemaVersion()).isAtLeast(44);
 
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -702,10 +704,10 @@
     assertQuery(searchOperator + "\"John Smith\"");
 
     // By invalid query.
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid value");
     // SchemaUtil.getNameParts will return an empty set for query only containing these characters.
-    assertQuery(searchOperator + "@.- /_");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery(searchOperator + "@.- /_"));
+    assertThat(thrown).hasMessageThat().contains("invalid value");
   }
 
   private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
@@ -1046,7 +1048,7 @@
   public void byLabelMulti() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Project.NameKey project =
-        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
+        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
 
     LabelType verified =
@@ -1196,9 +1198,9 @@
       }
       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)
+      assertWithMessage(q).that(results).hasSize(expectedSize);
+      assertWithMessage(q)
+          .that(results.get(results.size() - 1)._moreChanges)
           .isEqualTo(expectedMoreChanges);
       assertThat(results.get(0)._number).isEqualTo(last.getId().get());
     }
@@ -1936,7 +1938,7 @@
 
   @Test
   public void byDraftByExcludesZombieDrafts() throws Exception {
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
     Change.Id id = change.getId();
@@ -2255,7 +2257,7 @@
 
   @Test
   public void reviewerAndCcByEmail() throws Exception {
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -2301,7 +2303,7 @@
 
   @Test
   public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -2436,7 +2438,7 @@
   public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ObjectId missing =
-        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
+        repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
             .commit()
             .message("No change for this commit")
             .insertChangeId()
@@ -2451,7 +2453,7 @@
     List<String> shas = new ArrayList<>(n + extra.size());
     extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
-    Branch.NameKey dest = null;
+    BranchNameKey dest = null;
     for (int i = 0; i < n; i++) {
       ChangeInserter ins = newChange(repo);
       insert(repo, ins);
@@ -2467,15 +2469,15 @@
           queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), 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);
+      assertWithMessage(name).that(ids).hasSize(n);
+      assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
     }
   }
 
   @Test
   public void reindexIfStale() throws Exception {
     Account.Id user = createAccount("user");
-    Project.NameKey project = new Project.NameKey("repo");
+    Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
     String changeId = change.getKey().get();
@@ -2488,8 +2490,7 @@
     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()));
+    RefUpdate ru = repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.id()));
     ru.setForceUpdate(true);
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
 
@@ -2592,8 +2593,7 @@
     gApi.changes().id(changeToRevert.id).current().submit();
 
     ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
-    assertQueryByIds(
-        "revertof:" + changeToRevert._number, new Change.Id(changeThatReverts._number));
+    assertQueryByIds("revertof:" + changeToRevert._number, Change.id(changeThatReverts._number));
   }
 
   /** Change builder for helping in tests for dashboard sections. */
@@ -3209,7 +3209,7 @@
       branch = "refs/heads/" + branch;
     }
 
-    Change.Id id = new Change.Id(seq.nextChangeId());
+    Change.Id id = Change.id(seq.nextChangeId());
     ChangeInserter ins =
         changeFactory
             .create(id, commit, branch)
@@ -3236,7 +3236,7 @@
       Timestamp createdOn)
       throws Exception {
     Project.NameKey project =
-        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
+        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
     try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
@@ -3255,7 +3255,7 @@
 
     PatchSetInserter inserter =
         patchSetFactory
-            .create(changeNotesFactory.createChecked(c), new PatchSet.Id(c.getId(), n), commit)
+            .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
             .setFireRevisionCreated(false)
             .setValidate(false);
     try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.nowTs());
@@ -3295,7 +3295,7 @@
 
   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(Project.nameKey(name)));
   }
 
   protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
@@ -3303,7 +3303,7 @@
     input.name = name;
     input.parent = parent;
     gApi.projects().create(input).get();
-    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected QueryRequest newQuery(Object query) {
@@ -3327,8 +3327,8 @@
       throws Exception {
     List<ChangeInfo> result = query.get();
     Iterable<Change.Id> ids = ids(result);
-    assertThat(ids)
-        .named(format(query.getQuery(), ids, changes))
+    assertWithMessage(format(query.getQuery(), ids, changes))
+        .that(ids)
         .containsExactlyElementsIn(Arrays.asList(changes))
         .inOrder();
     return result;
@@ -3340,8 +3340,8 @@
             .map(ChangeData::getId)
             .collect(toImmutableList());
     Change.Id[] expectedIds = Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new);
-    assertThat(actualIds)
-        .named(format(predicate.toString(), actualIds, expectedIds))
+    assertWithMessage(format(predicate.toString(), actualIds, expectedIds))
+        .that(actualIds)
         .containsExactlyElementsIn(expectedIds)
         .inOrder();
   }
@@ -3372,7 +3372,7 @@
           .append(c.changeId)
           .append("), ")
           .append("dest=")
-          .append(new Branch.NameKey(new Project.NameKey(c.project), c.branch))
+          .append(BranchNameKey.create(Project.nameKey(c.project), c.branch))
           .append(", ")
           .append("status=")
           .append(c.status)
@@ -3393,7 +3393,7 @@
   }
 
   protected static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
-    return Streams.stream(changes).map(c -> new Change.Id(c._number)).collect(toList());
+    return Streams.stream(changes).map(c -> Change.id(c._number)).collect(toList());
   }
 
   protected static long lastUpdatedMs(Change c) {
@@ -3430,8 +3430,8 @@
   }
 
   protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
+        .that(getSchema().hasField(field))
         .isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index e8a63b8..7ca7ac3 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -64,6 +64,7 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index aba0018..0f7292d 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -21,22 +21,32 @@
 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.testing.GerritBaseTests;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ChangeDataTest extends GerritBaseTests {
+public class ChangeDataTest {
   @Test
   public void setPatchSetsClearsCurrentPatchSet() throws Exception {
-    Project.NameKey project = new Project.NameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
-    cd.setChange(TestChanges.newChange(project, new Account.Id(1000)));
+    Project.NameKey project = Project.nameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    cd.setChange(TestChanges.newChange(project, Account.id(1000)));
     PatchSet curr1 = cd.currentPatchSet();
-    int currId = curr1.getId().get();
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 1));
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 2));
+    int currId = curr1.id().get();
+    PatchSet ps1 = newPatchSet(cd.getId(), currId + 1);
+    PatchSet ps2 = newPatchSet(cd.getId(), currId + 2);
     cd.setPatchSets(ImmutableList.of(ps1, ps2));
     PatchSet curr2 = cd.currentPatchSet();
     assertThat(curr2).isNotSameInstanceAs(curr1);
   }
+
+  private static PatchSet newPatchSet(Change.Id changeId, int num) {
+    return PatchSet.builder()
+        .id(PatchSet.id(changeId, num))
+        .commitId(ObjectId.zeroId())
+        .uploader(Account.id(1234))
+        .createdOn(TimeUtil.nowTs())
+        .build();
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
index e550f8e..00c1a80 100644
--- a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
@@ -25,11 +25,10 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
-import com.google.gerrit.testing.GerritBaseTests;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ConflictKeyTest extends GerritBaseTests {
+public class ConflictKeyTest {
   @Test
   public void ffOnlyPreservesInputOrder() {
     ObjectId id1 = ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 2ea198f..621f474 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
@@ -76,8 +79,10 @@
     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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> assertQuery("owner: \"" + nameEmail + "\"\\", change1));
+    assertThat(thrown).hasMessageThat().contains("Cannot create full-text query with value: \\");
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java b/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
index 135e9c26..ac528f2e 100644
--- a/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
+++ b/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
@@ -19,11 +19,11 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Arrays;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class RegexPathPredicateTest extends GerritBaseTests {
+public class RegexPathPredicateTest {
   @Test
   public void prefixOnlyOptimization() {
     RegexPathPredicate p = predicate("^a/b/.*");
@@ -83,7 +83,8 @@
 
   private static ChangeData change(String... files) {
     Arrays.sort(files);
-    ChangeData cd = ChangeData.createForTest(new Project.NameKey("project"), new Change.Id(1), 1);
+    ChangeData cd =
+        ChangeData.createForTest(Project.nameKey("project"), Change.id(1), 1, ObjectId.zeroId());
     cd.setCurrentFilePaths(Arrays.asList(files));
     return cd;
   }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 4a3c755..3b13041 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.query.group;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
@@ -57,6 +59,7 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -68,10 +71,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryGroupsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -184,7 +190,7 @@
 
   @Test
   public void byInname() throws Exception {
-    String namePart = getSanitizedMethodName();
+    String namePart = testName.getSanitizedMethodName();
     namePart = CharMatcher.is('_').removeFrom(namePart);
 
     GroupInfo group1 = createGroup("group-" + namePart);
@@ -204,9 +210,9 @@
 
     assertQuery("description:non-existing");
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("description operator requires a value");
-    assertQuery("description:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("description:\"\""));
+    assertThat(thrown).hasMessageThat().contains("description operator requires a value");
   }
 
   @Test
@@ -340,7 +346,7 @@
 
     // update group in the database so that group index is stale
     String newDescription = "barY";
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group1.id);
+    AccountGroup.UUID groupUuid = AccountGroup.uuid(group1.id);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription(newDescription).build();
     groupsUpdateProvider.get().updateGroupInNoteDb(groupUuid, groupUpdate);
@@ -356,7 +362,7 @@
   @Test
   public void rawDocument() throws Exception {
     GroupInfo group1 = createGroup(name("group1"));
-    AccountGroup.UUID uuid = new AccountGroup.UUID(group1.id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(group1.id);
 
     Optional<FieldBundle> rawFields =
         indexes
@@ -376,7 +382,7 @@
   @Test
   public void byDeletedGroup() throws Exception {
     GroupInfo group = createGroup(name("group"));
-    AccountGroup.UUID uuid = new AccountGroup.UUID(group.id);
+    AccountGroup.UUID uuid = AccountGroup.uuid(group.id);
     String query = "uuid:" + uuid;
     assertQuery(query, group);
 
@@ -459,8 +465,8 @@
       throws Exception {
     List<GroupInfo> result = query.get();
     Iterable<String> uuids = uuids(result);
-    assertThat(uuids)
-        .named(format(query, result, groups))
+    assertWithMessage(format(query, result, groups))
+        .that(uuids)
         .containsExactlyElementsIn(uuids(groups))
         .inOrder();
     return result;
@@ -535,7 +541,7 @@
       return null;
     }
 
-    return name + "_" + getSanitizedMethodName();
+    return name + "_" + testName.getSanitizedMethodName();
   }
 
   protected int getSchemaVersion() {
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index 08ef2b0..8f13099 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.query.project;
 
 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.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
@@ -57,6 +59,7 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.GerritServerTests;
+import com.google.gerrit.testing.GerritTestName;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -68,10 +71,13 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 
 @Ignore
 public abstract class AbstractQueryProjectsTest extends GerritServerTests {
+  @Rule public final GerritTestName testName = new GerritTestName();
+
   @Inject protected Accounts accounts;
 
   @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdate;
@@ -185,7 +191,7 @@
 
   @Test
   public void byInname() throws Exception {
-    String namePart = getSanitizedMethodName();
+    String namePart = testName.getSanitizedMethodName();
     namePart = CharMatcher.is('_').removeFrom(namePart);
 
     ProjectInfo project1 = createProject(name("project1-" + namePart));
@@ -207,9 +213,9 @@
 
     assertQuery("description:non-existing");
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("description operator requires a value");
-    assertQuery("description:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("description:\"\""));
+    assertThat(thrown).hasMessageThat().contains("description operator requires a value");
   }
 
   @Test
@@ -224,16 +230,18 @@
 
   @Test
   public void byState_emptyQuery() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("state operator requires a value");
-    assertQuery("state:\"\"");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("state:\"\""));
+    assertThat(thrown).hasMessageThat().contains("state operator requires a value");
   }
 
   @Test
   public void byState_badQuery() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("state operator must be either 'active' or 'read-only'");
-    assertQuery("state:bla");
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("state:bla"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("state operator must be either 'active' or 'read-only'");
   }
 
   @Test
@@ -375,8 +383,8 @@
       throws Exception {
     List<ProjectInfo> result = query.get();
     Iterable<String> names = names(result);
-    assertThat(names)
-        .named(format(query, result, projects))
+    assertWithMessage(format(query, result, projects))
+        .that(names)
         .containsExactlyElementsIn(names(projects))
         .inOrder();
     return result;
@@ -443,6 +451,6 @@
       return null;
     }
 
-    return name + "_" + getSanitizedMethodName();
+    return name + "_" + testName.getSanitizedMethodName();
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 180c16b..655baa0 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.rules;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.expect;
 
 import com.google.gerrit.common.data.LabelTypes;
@@ -82,11 +84,14 @@
       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")));
+    ReductionLimitException thrown =
+        assertThrows(
+            ReductionLimitException.class,
+            () ->
+                env.once(
+                    Prolog.BUILTIN,
+                    "call",
+                    new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy"))));
+    assertThat(thrown).hasMessageThat().contains("exceeded reduction limit of 1300");
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index 14124fa..d2493cb 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -24,7 +24,6 @@
 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.testing.GerritBaseTests;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -32,9 +31,9 @@
 import java.util.List;
 import org.junit.Test;
 
-public class IgnoreSelfApprovalRuleTest extends GerritBaseTests {
-  private static final Change.Id CHANGE_ID = new Change.Id(100);
-  private static final PatchSet.Id PS_ID = new PatchSet.Id(CHANGE_ID, 1);
+public class IgnoreSelfApprovalRuleTest {
+  private static final Change.Id CHANGE_ID = Change.id(100);
+  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
   private static final LabelType VERIFIED = makeLabel("Verified");
   private static final Account.Id USER1 = makeAccount(100001);
 
@@ -82,16 +81,14 @@
   }
 
   private static PatchSetApproval makeApproval(LabelId labelId, Account.Id accountId, int value) {
-    PatchSetApproval.Key key = makeKey(PS_ID, accountId, labelId);
-    return new PatchSetApproval(key, (short) value, Date.from(Instant.now()));
-  }
-
-  private static PatchSetApproval.Key makeKey(
-      PatchSet.Id psId, Account.Id accountId, LabelId labelId) {
-    return new PatchSetApproval.Key(psId, accountId, labelId);
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, accountId, labelId))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
   }
 
   private static Account.Id makeAccount(int account) {
-    return new Account.Id(account);
+    return Account.id(account);
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
index 6eb0747..8622b32 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -16,10 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class PrologRuleEvaluatorTest extends GerritBaseTests {
+public class PrologRuleEvaluatorTest {
 
   @Test
   public void validLabelNamesAreKept() {
diff --git a/javatests/com/google/gerrit/server/rules/PrologTestCase.java b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
index f4d8eac..c2b6dbb 100644
--- a/javatests/com/google/gerrit/server/rules/PrologTestCase.java
+++ b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
@@ -19,7 +19,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.inject.Guice;
 import com.google.inject.Module;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -45,7 +44,7 @@
 
 /** Base class for any tests written in Prolog. */
 @Ignore
-public abstract class PrologTestCase extends GerritBaseTests {
+public abstract class PrologTestCase {
   private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
 
   private String pkg;
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index e5890c9..4c384e0 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GroupReference;
@@ -34,7 +35,6 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.Config;
@@ -43,7 +43,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class AllProjectsCreatorTest extends GerritBaseTests {
+public class AllProjectsCreatorTest {
   private static final LabelType TEST_LABEL =
       new LabelType(
           "Test-Label",
@@ -127,7 +127,7 @@
     allProjectsCreator.create(allProjectsInput);
 
     Config config = readAllProjectsConfig(repoManager, allProjectsName);
-    assertThat(config.getString("project", null, "description")).isEqualTo(testDescription);
+    assertThat(config).stringValue("project", null, "description").isEqualTo(testDescription);
   }
 
   @Test
@@ -143,7 +143,7 @@
     allProjectsCreator.create(allProjectsInput);
 
     Config config = readAllProjectsConfig(repoManager, allProjectsName);
-    assertThat(config.getBoolean("submit", null, "rejectEmptyCommit", false)).isTrue();
+    assertThat(config).booleanValue("submit", null, "rejectEmptyCommit", false).isTrue();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index b6887cf..9d43f67 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.notedb.IntBlob;
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestUpdateUI;
 import java.io.IOException;
@@ -44,7 +43,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
-public class NoteDbSchemaUpdaterTest extends GerritBaseTests {
+public class NoteDbSchemaUpdaterTest {
   @Test
   public void requiredUpgradesFromNoVersion() throws Exception {
     assertThat(requiredUpgrades(0, versions(10))).containsExactly(10).inOrder();
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
index 9c62d7f..464a452 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
@@ -21,14 +21,13 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 
-public class NoteDbSchemaVersionManagerTest extends GerritBaseTests {
+public class NoteDbSchemaVersionManagerTest {
   private NoteDbSchemaVersionManager manager;
   private TestRepository<?> tr;
 
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
index 7bc3848..31697fd 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
@@ -24,11 +24,10 @@
 import com.google.common.collect.Streams;
 import com.google.common.reflect.ClassPath;
 import com.google.common.reflect.ClassPath.ClassInfo;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.stream.IntStream;
 import org.junit.Test;
 
-public class NoteDbSchemaVersionsTest extends GerritBaseTests {
+public class NoteDbSchemaVersionsTest {
   @Test
   public void testGuessVersion() {
     assertThat(guessVersion(getClass())).isEmpty();
diff --git a/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
index e4089c5..bc5109f 100644
--- a/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
+++ b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
@@ -103,7 +103,7 @@
       return factory
           .read(
               new MetaDataUpdate(
-                  GitReferenceUpdated.DISABLED, new Project.NameKey(ALL_PROJECTS), repo, null))
+                  GitReferenceUpdated.DISABLED, Project.nameKey(ALL_PROJECTS), repo, null))
           .getConfig();
     }
   }
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 35f580c..d9ed577 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -36,7 +35,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class SchemaCreatorImplTest extends GerritBaseTests {
+public class SchemaCreatorImplTest {
   @Inject private AllProjectsName allProjects;
 
   @Inject private GitRepositoryManager repoManager;
diff --git a/javatests/com/google/gerrit/server/schema/TestGroup.java b/javatests/com/google/gerrit/server/schema/TestGroup.java
index 4627e8b..c8b53d3 100644
--- a/javatests/com/google/gerrit/server/schema/TestGroup.java
+++ b/javatests/com/google/gerrit/server/schema/TestGroup.java
@@ -47,7 +47,7 @@
     public abstract Builder setNameKey(AccountGroup.NameKey nameKey);
 
     public Builder setName(String name) {
-      return setNameKey(new AccountGroup.NameKey(name));
+      return setNameKey(AccountGroup.nameKey(name));
     }
 
     public abstract Builder setGroupUuid(AccountGroup.UUID uuid);
@@ -66,10 +66,9 @@
 
     public AccountGroup build() {
       TestGroup testGroup = autoBuild();
-      AccountGroup.NameKey name = testGroup.getNameKey().orElse(new AccountGroup.NameKey("users"));
-      AccountGroup.Id id = testGroup.getId().orElse(new AccountGroup.Id(Math.abs(name.hashCode())));
-      AccountGroup.UUID uuid =
-          testGroup.getGroupUuid().orElse(new AccountGroup.UUID(name + "-UUID"));
+      AccountGroup.NameKey name = testGroup.getNameKey().orElse(AccountGroup.nameKey("users"));
+      AccountGroup.Id id = testGroup.getId().orElse(AccountGroup.id(Math.abs(name.hashCode())));
+      AccountGroup.UUID uuid = testGroup.getGroupUuid().orElse(AccountGroup.uuid(name + "-UUID"));
       Timestamp createdOn = testGroup.getCreatedOn().orElseGet(TimeUtil::nowTs);
       AccountGroup accountGroup = new AccountGroup(name, id, uuid, createdOn);
       testGroup.getOwnerGroupUuid().ifPresent(accountGroup::setOwnerGroupUUID);
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index efe24d5..6831fa3 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -4,11 +4,15 @@
     name = "small_tests",
     size = "small",
     srcs = glob(["*.java"]),
+    runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index cca844e..79faf60 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -14,36 +14,67 @@
 
 package com.google.gerrit.server.update;
 
+import static com.google.common.base.Preconditions.checkArgument;
+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 com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubmitRecord;
+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.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.notedb.TooManyUpdatesException;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.eclipse.jgit.junit.TestRepository;
+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.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 
-public class BatchUpdateTest extends GerritBaseTests {
-  @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
+public class BatchUpdateTest {
+  private static final int MAX_UPDATES = 4;
 
-  @Inject private GitRepositoryManager repoManager;
+  @Rule
+  public InMemoryTestEnvironment testEnvironment =
+      new InMemoryTestEnvironment(
+          () -> {
+            Config cfg = new Config();
+            cfg.setInt("change", null, "maxUpdates", MAX_UPDATES);
+            return cfg;
+          });
+
   @Inject private BatchUpdate.Factory batchUpdateFactory;
+  @Inject private ChangeInserter.Factory changeInserterFactory;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
+  @Inject private Sequences sequences;
 
   private Project.NameKey project;
   private TestRepository<Repository> repo;
 
   @Before
   public void setUp() throws Exception {
-    project = new Project.NameKey("test");
+    project = Project.nameKey("test");
 
     Repository inMemoryRepo = repoManager.createRepository(project);
     repo = new TestRepository<>(inMemoryRepo);
@@ -68,4 +99,222 @@
     assertThat(repo.getRepository().exactRef("refs/heads/master").getObjectId())
         .isEqualTo(branchCommit.getId());
   }
+
+  @Test
+  public void cannotExceedMaxUpdates() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Excessive update"));
+      try {
+        bu.execute();
+        assert_().fail("expected ResourceConflictException");
+      } catch (ResourceConflictException e) {
+        assertThat(e).hasMessageThat().isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+      }
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void cannotExceedMaxUpdatesCountingMultipleChangeUpdatesInSingleBatch() throws Exception {
+    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1)));
+      bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2)));
+      try {
+        bu.execute();
+        assert_().fail("expected ResourceConflictException");
+      } catch (ResourceConflictException e) {
+        assertThat(e).hasMessageThat().isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+      }
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES - 1);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              return false;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithNoOpAfterPopulatingUpdate() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage("No-op");
+              return false;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
+    assertThat(getMetaId(id)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithSubmit() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new SubmitOp());
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception {
+    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1)));
+      bu.addOp(id, new SubmitOp());
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+  // Not possible to write a variant of this test that submits first and adds a message second in
+  // the same batch, since submit always comes last.
+
+  @Test
+  public void exceedingMaxUpdatesAllowedWithAbandon() throws Exception {
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES);
+    ObjectId oldMetaId = getMetaId(id);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+              update.setChangeMessage("Abandon");
+              update.setStatus(Change.Status.ABANDONED);
+              return true;
+            }
+          });
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1);
+    assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
+  }
+
+  private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception {
+    checkArgument(totalUpdates > 0);
+    checkArgument(totalUpdates <= MAX_UPDATES);
+    Change.Id id = Change.id(sequences.nextChangeId());
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.insertChange(
+          changeInserterFactory.create(
+              id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
+      bu.execute();
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(1);
+    for (int i = 2; i <= totalUpdates; i++) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+        bu.addOp(id, new AddMessageOp("Update " + i));
+        bu.execute();
+      }
+    }
+    assertThat(getUpdateCount(id)).isEqualTo(totalUpdates);
+    return id;
+  }
+
+  private Change.Id createChangeWithTwoPatchSets(int totalUpdates) throws Exception {
+    Change.Id id = createChangeWithUpdates(totalUpdates - 1);
+    ChangeNotes notes = changeNotesFactory.create(project, id);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      ObjectId commitId = repo.amend(notes.getCurrentPatchSet().commitId()).message("PS2").create();
+      bu.addOp(
+          id,
+          patchSetInserterFactory
+              .create(notes, PatchSet.id(id, 2), commitId)
+              .setMessage("Add PS2"));
+      bu.execute();
+    }
+
+    assertThat(getUpdateCount(id)).isEqualTo(totalUpdates);
+    return id;
+  }
+
+  private static class AddMessageOp implements BatchUpdateOp {
+    private final String message;
+    @Nullable private final PatchSet.Id psId;
+
+    AddMessageOp(String message) {
+      this(message, null);
+    }
+
+    AddMessageOp(String message, PatchSet.Id psId) {
+      this.message = message;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      PatchSet.Id psIdToUpdate = psId;
+      if (psIdToUpdate == null) {
+        psIdToUpdate = ctx.getChange().currentPatchSetId();
+      } else {
+        checkState(
+            ctx.getNotes().getPatchSets().containsKey(psIdToUpdate),
+            "%s not in %s",
+            psIdToUpdate,
+            ctx.getNotes().getPatchSets().keySet());
+      }
+      ctx.getUpdate(psIdToUpdate).setChangeMessage(message);
+      return true;
+    }
+  }
+
+  private int getUpdateCount(Change.Id changeId) throws Exception {
+    return changeNotesFactory.create(project, changeId).getUpdateCount();
+  }
+
+  private ObjectId getMetaId(Change.Id changeId) throws Exception {
+    return repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId();
+  }
+
+  private static class SubmitOp implements BatchUpdateOp {
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      SubmitRecord sr = new SubmitRecord();
+      sr.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label cr = new SubmitRecord.Label();
+      cr.status = SubmitRecord.Label.Status.OK;
+      cr.appliedBy = ctx.getAccountId();
+      cr.label = "Code-Review";
+      sr.labels = ImmutableList.of(cr);
+      ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+      update.merge(new RequestId(), ImmutableList.of(sr));
+      update.setChangeMessage("Submitted");
+      return true;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/update/RepoViewTest.java b/javatests/com/google/gerrit/server/update/RepoViewTest.java
index b41c66c..ea80633 100644
--- a/javatests/com/google/gerrit/server/update/RepoViewTest.java
+++ b/javatests/com/google/gerrit/server/update/RepoViewTest.java
@@ -19,7 +19,6 @@
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -31,7 +30,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class RepoViewTest extends GerritBaseTests {
+public class RepoViewTest {
   private static final String MASTER = "refs/heads/master";
   private static final String BRANCH = "refs/heads/branch";
 
@@ -42,7 +41,7 @@
   @Before
   public void setUp() throws Exception {
     InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
-    Project.NameKey project = new Project.NameKey("project");
+    Project.NameKey project = Project.nameKey("project");
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     tr.branch(MASTER).commit().create();
diff --git a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
index e702656..808eca8 100644
--- a/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
+++ b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
@@ -17,11 +17,10 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.HashSet;
 import org.junit.Test;
 
-public class IdGeneratorTest extends GerritBaseTests {
+public class IdGeneratorTest {
   @Test
   public void test1234() {
     final HashSet<Integer> seen = new HashSet<>();
diff --git a/javatests/com/google/gerrit/server/util/LabelVoteTest.java b/javatests/com/google/gerrit/server/util/LabelVoteTest.java
index 3048b75..9069928 100644
--- a/javatests/com/google/gerrit/server/util/LabelVoteTest.java
+++ b/javatests/com/google/gerrit/server/util/LabelVoteTest.java
@@ -19,10 +19,9 @@
 import static com.google.gerrit.server.util.LabelVote.parse;
 import static com.google.gerrit.server.util.LabelVote.parseWithEquals;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class LabelVoteTest extends GerritBaseTests {
+public class LabelVoteTest {
   @Test
   public void labelVoteParse() {
     assertLabelVoteEquals(parse("Code-Review-2"), "Code-Review", -2);
diff --git a/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java b/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
index 9ea17f3..025bf84 100644
--- a/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
+++ b/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
@@ -16,10 +16,9 @@
 
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import org.junit.Test;
 
-public class MostSpecificComparatorTest extends GerritBaseTests {
+public class MostSpecificComparatorTest {
 
   private MostSpecificComparator cmp;
 
diff --git a/javatests/com/google/gerrit/server/util/SocketUtilTest.java b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
index 018b8db..25114f9 100644
--- a/javatests/com/google/gerrit/server/util/SocketUtilTest.java
+++ b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.server.util;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.util.SocketUtil.hostname;
 import static com.google.gerrit.server.util.SocketUtil.isIPv6;
 import static com.google.gerrit.server.util.SocketUtil.parse;
 import static com.google.gerrit.server.util.SocketUtil.resolve;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.net.InetAddress.getByName;
 import static java.net.InetSocketAddress.createUnresolved;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
@@ -32,7 +33,7 @@
 import java.net.UnknownHostException;
 import org.junit.Test;
 
-public class SocketUtilTest extends GerritBaseTests {
+public class SocketUtilTest {
   @Test
   public void testIsIPv6() throws UnknownHostException {
     final InetAddress ipv6 = getByName("1:2:3:4:5:6:7:8");
@@ -105,16 +106,16 @@
 
   @Test
   public void testParseInvalidIPv6() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid IPv6: [:3");
-    parse("[:3", 80);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> parse("[:3", 80));
+    assertThat(thrown).hasMessageThat().contains("invalid IPv6: [:3");
   }
 
   @Test
   public void testParseInvalidPort() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid port: localhost:A");
-    parse("localhost:A", 80);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> parse("localhost:A", 80));
+    assertThat(thrown).hasMessageThat().contains("invalid port: localhost:A");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
index c5e683f..50f28ab 100644
--- a/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
+++ b/javatests/com/google/gerrit/server/util/git/SubmoduleSectionParserTest.java
@@ -17,20 +17,19 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.BranchNameKey;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-public class SubmoduleSectionParserTest extends GerritBaseTests {
+public class SubmoduleSectionParserTest {
   private static final String THIS_SERVER = "http://localhost/";
 
   @Test
   public void followMasterBranch() throws Exception {
-    Project.NameKey p = new Project.NameKey("proj");
+    Project.NameKey p = Project.nameKey("proj");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -40,7 +39,7 @@
             + p.get()
             + "\n"
             + "branch = master\n");
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
@@ -48,14 +47,14 @@
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
             new SubmoduleSubscription(
-                targetBranch, new Branch.NameKey(p, "master"), "localpath-to-a"));
+                targetBranch, BranchNameKey.create(p, "master"), "localpath-to-a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void followMatchingBranch() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -66,32 +65,32 @@
             + "\n"
             + "branch = .\n");
 
-    Branch.NameKey targetBranch1 = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch1 = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res1 =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch1).parseAllSections();
 
     Set<SubmoduleSubscription> expected1 =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch1, new Branch.NameKey(p, "master"), "a"));
+            new SubmoduleSubscription(targetBranch1, BranchNameKey.create(p, "master"), "a"));
 
     assertThat(res1).containsExactlyElementsIn(expected1);
 
-    Branch.NameKey targetBranch2 = new Branch.NameKey(new Project.NameKey("project"), "somebranch");
+    BranchNameKey targetBranch2 = BranchNameKey.create(Project.nameKey("project"), "somebranch");
 
     Set<SubmoduleSubscription> res2 =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch2).parseAllSections();
 
     Set<SubmoduleSubscription> expected2 =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch2, new Branch.NameKey(p, "somebranch"), "a"));
+            new SubmoduleSubscription(targetBranch2, BranchNameKey.create(p, "somebranch"), "a"));
 
     assertThat(res2).containsExactlyElementsIn(expected2);
   }
 
   @Test
   public void followAnotherBranch() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -102,21 +101,21 @@
             + "\n"
             + "branch = anotherbranch\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "anotherbranch"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "anotherbranch"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withAnotherURI() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -127,21 +126,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withSlashesInProjectName() throws Exception {
-    Project.NameKey p = new Project.NameKey("project/with/slashes/a");
+    Project.NameKey p = Project.nameKey("project/with/slashes/a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -152,21 +151,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withSlashesInPath() throws Exception {
-    Project.NameKey p = new Project.NameKey("a");
+    Project.NameKey p = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -177,22 +176,23 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a/b/c/d/e"));
+            new SubmoduleSubscription(
+                targetBranch, BranchNameKey.create(p, "master"), "a/b/c/d/e"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withMoreSections() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
-    Project.NameKey p2 = new Project.NameKey("b");
+    Project.NameKey p1 = Project.nameKey("a");
+    Project.NameKey p2 = Project.nameKey("b");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -209,23 +209,23 @@
             + "\n"
             + "		branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p2, "master"), "b"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p2, "master"), "b"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withSubProjectFound() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a/b");
-    Project.NameKey p2 = new Project.NameKey("b");
+    Project.NameKey p1 = Project.nameKey("a/b");
+    Project.NameKey p2 = Project.nameKey("b");
     Config cfg = new Config();
     cfg.fromText(
         "\n"
@@ -242,25 +242,25 @@
             + "\n"
             + "branch = .\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p2, "master"), "b"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a/b"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p2, "master"), "b"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a/b"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withAnInvalidSection() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
-    Project.NameKey p2 = new Project.NameKey("b");
-    Project.NameKey p3 = new Project.NameKey("d");
-    Project.NameKey p4 = new Project.NameKey("e");
+    Project.NameKey p1 = Project.nameKey("a");
+    Project.NameKey p2 = Project.nameKey("b");
+    Project.NameKey p3 = Project.nameKey("d");
+    Project.NameKey p4 = Project.nameKey("e");
     Config cfg = new Config();
     cfg.fromText(
         "\n"
@@ -293,15 +293,15 @@
             + "\n"
             + "    branch = refs/heads/master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"),
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p4, "master"), "e"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"),
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p4, "master"), "e"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
@@ -317,7 +317,7 @@
             // Project "a" doesn't exist
             + "branch = .\\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
@@ -327,7 +327,7 @@
 
   @Test
   public void withSectionToOtherServer() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p1 = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -338,7 +338,7 @@
             + "\n"
             + "branch = .");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
@@ -348,7 +348,7 @@
 
   @Test
   public void withRelativeURI() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p1 = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -359,21 +359,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("a");
+    Project.NameKey p1 = Project.nameKey("a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -384,22 +384,21 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch =
-        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("nested/project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
   public void withOverlyDeepRelativeURI() throws Exception {
-    Project.NameKey p1 = new Project.NameKey("nested/a");
+    Project.NameKey p1 = Project.nameKey("nested/a");
     Config cfg = new Config();
     cfg.fromText(
         ""
@@ -410,15 +409,14 @@
             + "\n"
             + "branch = master\n");
 
-    Branch.NameKey targetBranch =
-        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
+    BranchNameKey targetBranch = BranchNameKey.create(Project.nameKey("nested/project"), "master");
 
     Set<SubmoduleSubscription> res =
         new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     Set<SubmoduleSubscription> expected =
         Sets.newHashSet(
-            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
+            new SubmoduleSubscription(targetBranch, BranchNameKey.create(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
diff --git a/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java b/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
index b663849..777cb4f 100644
--- a/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
+++ b/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
@@ -17,13 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.extensions.api.projects.ConfigValue;
-import com.google.gerrit.testing.GerritBaseTests;
 import java.util.Collections;
 import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 
-public class ProjectConfigParamParserTest extends GerritBaseTests {
+public class ProjectConfigParamParserTest {
 
   private CreateProjectCommand cmd;
 
diff --git a/javatests/com/google/gerrit/testing/GerritJUnitTest.java b/javatests/com/google/gerrit/testing/GerritJUnitTest.java
new file mode 100644
index 0000000..430f48f
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/GerritJUnitTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+
+public class GerritJUnitTest {
+  private static class MyException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    MyException(String msg) {
+      super(msg);
+    }
+  }
+
+  private static class MySubException extends MyException {
+    private static final long serialVersionUID = 1L;
+
+    MySubException(String msg) {
+      super(msg);
+    }
+  }
+
+  @Test
+  public void assertThrowsCatchesSpecifiedExceptionType() {
+    MyException e =
+        assertThrows(
+            MyException.class,
+            () -> {
+              throw new MyException("foo");
+            });
+    assertThat(e).hasMessageThat().isEqualTo("foo");
+  }
+
+  @Test
+  public void assertThrowsCatchesSubclassOfSpecifiedExceptionType() {
+    MyException e =
+        assertThrows(
+            MyException.class,
+            () -> {
+              throw new MySubException("foo");
+            });
+    assertThat(e).isInstanceOf(MySubException.class);
+    assertThat(e).hasMessageThat().isEqualTo("foo");
+  }
+
+  @Test
+  public void assertThrowsConvertsUnexpectedExceptionTypeToAssertionError() {
+    try {
+      assertThrows(
+          IllegalStateException.class,
+          () -> {
+            throw new MyException("foo");
+          });
+      assert_().fail("expected AssertionError");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessageThat().contains(IllegalStateException.class.getSimpleName());
+      assertThat(e).hasMessageThat().contains(MyException.class.getSimpleName());
+      assertThat(e).hasCauseThat().isInstanceOf(MyException.class);
+      assertThat(e).hasCauseThat().hasMessageThat().isEqualTo("foo");
+    }
+  }
+
+  @Test
+  public void assertThrowsThrowsAssertionErrorWhenNothingThrown() {
+    try {
+      assertThrows(MyException.class, () -> {});
+      assert_().fail("expected AssertionError");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessageThat().contains(MyException.class.getSimpleName());
+      assertThat(e).hasCauseThat().isNull();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/testing/IndexVersionsTest.java b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
index 36247f8..0362ddc 100644
--- a/javatests/com/google/gerrit/testing/IndexVersionsTest.java
+++ b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.IndexVersions.ALL;
 import static com.google.gerrit.testing.IndexVersions.CURRENT;
 import static com.google.gerrit.testing.IndexVersions.PREVIOUS;
@@ -25,7 +26,7 @@
 import java.util.List;
 import org.junit.Test;
 
-public class IndexVersionsTest extends GerritBaseTests {
+public class IndexVersionsTest {
   private static final ChangeSchemaDefinitions SCHEMA_DEF = ChangeSchemaDefinitions.INSTANCE;
 
   @Test
@@ -133,8 +134,8 @@
   }
 
   private void assertIllegalArgument(String value, String expectedMessage) {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(expectedMessage);
-    get(value);
+    IllegalArgumentException thrown =
+        assertThrows(IllegalArgumentException.class, () -> get(value));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 }
diff --git a/javatests/com/google/gerrit/util/http/RequestUtilTest.java b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
index adda5e7..bef9d4b1 100644
--- a/javatests/com/google/gerrit/util/http/RequestUtilTest.java
+++ b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
@@ -18,11 +18,10 @@
 import static com.google.gerrit.util.http.RequestUtil.getEncodedPathInfo;
 import static com.google.gerrit.util.http.RequestUtil.getRestPathWithoutIds;
 
-import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import org.junit.Test;
 
-public class RequestUtilTest extends GerritBaseTests {
+public class RequestUtilTest {
   @Test
   public void getEncodedPathInfo_emptyContextPath() {
     assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f096e2a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,25 @@
+{
+  "name": "gerrit",
+  "version": "3.1.0-SNAPSHOT",
+  "description": "Gerrit Code Review",
+  "dependencies": {},
+  "devDependencies": {
+    "eslint": "^5.16.0",
+    "eslint-config-google": "^0.13.0",
+    "eslint-plugin-html": "^5.0.5",
+    "fried-twinkie": "^0.2.2",
+    "typescript": "^2.x.x",
+    "web-component-tester": "^6.5.0"
+  },
+  "scripts": {
+    "test": "WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh",
+    "eslint": "./node_modules/eslint/bin/eslint.js --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app || exit 0",
+    "test-template": "./polygerrit-ui/app/run_template_test.sh"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://gerrit.googlesource.com/gerrit"
+  },
+  "author": "",
+  "license": "Apache-2.0"
+}
diff --git a/plugins/BUILD b/plugins/BUILD
index 8baa3c7..3663c7d 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -46,6 +46,7 @@
     "//java/com/google/gerrit/server/api",
     "//java/com/google/gerrit/server/audit",
     "//java/com/google/gerrit/server/cache/mem",
+    "//java/com/google/gerrit/server/cache/serialize",
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
diff --git a/plugins/delete-project b/plugins/delete-project
index 776d04d..a4b777a 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 776d04d40edca8b2a6cacd0e567f09d6ea37c851
+Subproject commit a4b777a173feb2cfaaad591e2fded37f15500e2b
diff --git a/plugins/download-commands b/plugins/download-commands
index 697ca37..8914550 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 697ca3783add1c8fa406d60f7821ab6db7c1bfec
+Subproject commit 891455076417dd097fdfd63f4afc0d28a3e85aff
diff --git a/plugins/hooks b/plugins/hooks
index 4928874..60fb334 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 492887449bf66b0f828aa26506629a8f5838f4d3
+Subproject commit 60fb334f44329caca37d8aa0d43feba651c959b2
diff --git a/plugins/replication b/plugins/replication
index f2543e6..aa07963 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit f2543e6753bc516322853f288dce1c45b9767899
+Subproject commit aa07963def69e4423444a078a662072e09629cec
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 3dec016..2091fdf 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 3dec016f9186131b2359d689a3e4a2c14717710e
+Subproject commit 2091fdfc99f2cec60ae3fda2538ae1993b0fc8b8
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 25bcffa..ce8b761 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 25bcffa8e0a35df7f62b70b7dd47d2284f3a777a
+Subproject commit ce8b76168588c0ee5fbb04bee31ba89088957c86
diff --git a/plugins/webhooks b/plugins/webhooks
index aaf9161..1c860ae 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit aaf916114dbaf822cad134152778c615788e9abd
+Subproject commit 1c860ae557f3851b2d98978bafba644de5e6c0b8
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index cd739a1..737863c 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -1,4 +1,8 @@
-# PolyGerrit
+# Gerrit Polymer Frontend
+
+Follow the
+[setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
+where applicable.
 
 ## Installing [Bazel](https://bazel.build/)
 
@@ -20,77 +24,82 @@
 brew install npm
 ```
 
-All other platforms: [download from
-nodejs.org](https://nodejs.org/en/download/).
+All other platforms:
+[download from nodejs.org](https://nodejs.org/en/download/).
 
 Various steps below require installing additional npm packages. The full list of
 dependencies can be installed with:
 
 ```sh
-sudo npm install -g \
-  eslint \
-  eslint-config-google \
-  eslint-plugin-html \
-  typescript \
-  fried-twinkie \
-  polylint \
-  web-component-tester
+npm install
+sudo npm install -g polylint
 ```
 
 It may complain about a missing `typescript@2.3.4` peer dependency, which is
 harmless.
 
-If you're interested in the details, keep reading.
+## Running locally against production data
 
-## Local UI, Production Data
+#### Go server
 
-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.
-
-### Running the server
-
-To test the local UI against gerrit-review.googlesource.com:
+To test the local Polymer frontend against gerrit-review.googlesource.com
+simply execute:
 
 ```sh
-./run-server.sh
+./polygerrit-ui/run-server.sh
 ```
 
-Then visit http://localhost:8081
+Then visit <http://localhost:8081>.
 
-## Local UI, Test Data
-
-One-time setup:
-
-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/linux-quickstart.html) and
-   [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
-
-When your project is set up and works using the classic UI, run a test server
-that serves PolyGerrit:
+This method is based on a
+[simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
+Mostly it just switches between serving files locally and proxying the real
+server based on the file name. It also does some basic response rewriting, e.g.
+it patches the `config/server/info` response with plugin information provided on
+the command line:
 
 ```sh
+<<<<<<< HEAD
+./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
+=======
 bazel build gerrit &&
   $(bazel info output_base)/external/local_jdk/bin/java -DsourceRoot=/path/to/my/checkout \
   -jar bazel-bin/gerrit.war daemon --polygerrit-dev \
   -d ../gerrit_testsite --console-log --show-stack-trace
+>>>>>>> stable-3.0
 ```
 
-Serving plugins
+The biggest draw back of this method is that you cannot log in, so cannot test
+scenarios that require it.
 
-> Local dev plugins must be put inside of gerrit/plugins
+#### MITM Proxy
 
-Loading a single plugin file:
+[MITM Proxy](https://mitmproxy.org/) is an open source product for proxying
+https servers. The
+[contrib/mitm-ui/](https://gerrit.googlesource.com/gerrit/+/master/contrib/mitm-ui/)
+directory contains scripts (and documentation) for using this technology
+(instead of the Go server). These scripts are somewhat experimental and
+unmaintained though.
+
+## Running locally against a Gerrit test site
+
+Set up a local test site once:
+
+1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
+2. [Set up a local test site](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
+3. Optionally [populate](https://gerrit.googlesource.com/gerrit/+/master/contrib/populate-fixture-data.py) your test site with some test data.
+
+For running a locally built Gerrit war against your test instance use
+[this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon),
+and add the `--polygerrit-dev` option, if you want to serve the Polymer frontend
+directly from the sources in `polygerrit_ui/app/` instead of from the war:
 
 ```sh
-./run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
-```
-
-Loading multiple plugin files:
-
-```sh
-./run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
+$(bazel info output_base)/external/local_jdk/bin/java \
+    -jar bazel-bin/gerrit.war daemon \
+    -d $GERRIT_SITE \
+    --console-log \
+    --polygerrit-dev
 ```
 
 ## Running Tests
@@ -100,10 +109,17 @@
 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:
+For daily development you typically only want to run and debug individual tests.
+Run the local [Go proxy server](#go-server) and navigate for example to
+<http://localhost:8081/elements/change/gr-account-entry/gr-account-entry_test.html>.
+Check "Disable cache" in the "Network" tab of Chrome's dev tools, so code
+changes are picked up on "reload".
+
+Our CI integration ensures that all tests are run when you upload a change to
+Gerrit, but you can also run all tests locally in headless mode:
 
 ```sh
-./polygerrit-ui/app/run_test.sh
+npm test
 ```
 
 To allow the tests to run in Safari:
@@ -111,33 +127,12 @@
 * In the Advanced preferences tab, check "Show Develop menu in menu bar".
 * In the Develop menu, enable the "Allow Remote Automation" option.
 
-If you need to pass additional arguments to `wct`:
-
-```sh
-WCT_ARGS='-p --some-flag="foo bar"' ./polygerrit-ui/app/run_test.sh
-```
-
-For interactively working on a single test file, do the following:
-
-```sh
-./polygerrit-ui/run-server.sh
-```
-
-Then visit http://localhost:8081/elements/foo/bar_test.html and check "Disable
-cache" in the "Network" tab of Chrome's dev tools, so code changes are picked
-up on "reload".
-
 To run Chrome tests in headless mode:
 
 ```sh
-WCT_HEADLESS_MODE=1 ./polygerrit-ui/app/run_test.sh
+WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./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)
@@ -161,11 +156,22 @@
 Some useful commands:
 
 * To run ESLint on the whole app, less some dependency code:
-`eslint --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app`
+
+```sh
+npm run eslint
+```
+
 * To run ESLint on just the subdirectory you modified:
-`eslint --ext .html,.js polygerrit-ui/app/$YOUR_DIR_HERE`
+
+```sh
+node_modules/eslint/bin/eslint.js --ext .html,.js polygerrit-ui/app/$YOUR_DIR_HERE
+```
+
 * To run the linter on all of your local changes:
-`git diff --name-only master | xargs eslint --ext .html,.js`
+
+```sh
+git diff --name-only master | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
+```
 
 We also use the `polylint` tool to lint use of Polymer. To install polylint,
 execute the following command.
@@ -176,11 +182,14 @@
 bazel test //polygerrit-ui/app:polylint_test
 ```
 ## Template Type Safety
-Polymer elements are not type checked against the element definition, making it trivial to break the display when refactoring or moving code. We now run additional tests to help ensure that template types are checked.
+Polymer elements are not type checked against the element definition, making it
+trivial to break the display when refactoring or moving code. We now run
+additional tests to help ensure that template types are checked.
 
 A few notes to ensure that these tests pass
 - Any functions with optional parameters will need closure annotations.
-- Any Polymer parameters that are nullable or can be multiple types (other than the one explicitly delared) will need type annotations.
+- Any Polymer parameters that are nullable or can be multiple types (other than
+  the one explicitly delared) will need type annotations.
 
 These tests require the `typescript` and `fried-twinkie` npm packages.
 
@@ -190,6 +199,12 @@
 ./polygerrit-ui/app/run_template_test.sh
 ```
 
+or
+
+```sh
+npm run test-template
+```
+
 To run on a specific top level directory (ex: change-list)
 ```sh
 TEMPLATE_NO_DEFAULT=true ./polygerrit-ui/app/run_template_test.sh //polygerrit-ui/app:template_test_change-list
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index 6adf127..41ce201 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -95,7 +95,8 @@
         // For a new section, this is not fired because new permissions and
         // rules have to be added in order to save, modifying the ref is not
         // enough.
-        this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'access-modified', {bubbles: true, composed: true}));
       }
       this.section.value.updatedId = this.section.id;
     },
@@ -199,12 +200,13 @@
 
     _handleRemoveReference() {
       if (this.section.value.added) {
-        this.dispatchEvent(new CustomEvent('added-section-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-section-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.section.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleUndoRemove() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index 76bd558..8a262b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -205,6 +205,7 @@
               this.dispatchEvent(new CustomEvent('show-alert', {
                 detail: {message: SAVING_ERROR_TEXT},
                 bubbles: true,
+                composed: true,
               }));
               return err;
             }
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index a131f4f..1c408df 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -137,17 +137,19 @@
     _handleValueChange() {
       this.permission.value.modified = true;
       // Allows overall access page to know a change has been made.
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleRemovePermission() {
       if (this.permission.value.added) {
-        this.dispatchEvent(new CustomEvent('added-permission-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-permission-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.permission.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleRulesChanged(changeRecord) {
@@ -273,7 +275,8 @@
       const value = this._rules[this._rules.length - 1].value;
       value.added = true;
       this.set(['permission', 'value', 'rules', groupId], value);
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _computeHasRange(name) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 713bf02..7d2890a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -406,8 +406,11 @@
       if (!Object.keys(addRemoveObj.add).length &&
           !Object.keys(addRemoveObj.remove).length &&
           !addRemoveObj.parent) {
-        this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message: NOTHING_TO_SAVE}, bubbles: true}));
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: NOTHING_TO_SAVE},
+          bubbles: true,
+          composed: true,
+        }));
         return;
       }
       const obj = {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
index e0becaf..04d9781 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
@@ -34,7 +34,8 @@
      */
 
     _onCommandTap() {
-      this.dispatchEvent(new CustomEvent('command-tap', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('command-tap', {bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 92b11fc..169672a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -75,8 +75,9 @@
     _handleRunningGC() {
       return this.$.restAPI.runRepoGC(this.repo).then(response => {
         if (response.status === 200) {
-          this.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: GC_MESSAGE}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent(
+              'show-alert',
+              {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
         }
       });
     },
@@ -100,8 +101,9 @@
             const message = change ?
                 CREATE_CHANGE_SUCCEEDED_MESSAGE :
                 CREATE_CHANGE_FAILED_MESSAGE;
-            this.dispatchEvent(new CustomEvent('show-alert',
-                {detail: {message}, bubbles: true}));
+            this.dispatchEvent(new CustomEvent(
+                'show-alert',
+                {detail: {message}, bubbles: true, composed: true}));
             if (!change) { return; }
 
             Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
index 6d7677e..883a4e1 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -123,8 +123,8 @@
         notifyPath: `${name}.${notifyPath}`,
       };
 
-      this.dispatchEvent(new CustomEvent(this.PLUGIN_CONFIG_CHANGED,
-          {detail, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
index 2af2043..37f43f4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
@@ -151,7 +151,8 @@
         const select = element.$$('select');
         assert.ok(select);
         select.value = 'newTest';
-        select.dispatchEvent(new Event('change', {bubbles: true}));
+        select.dispatchEvent(new Event(
+            'change', {bubbles: true, composed: true}));
         flushAsynchronousOperations();
 
         assert.isTrue(buildStub.called);
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index a45b391..4102747 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -210,12 +210,13 @@
 
     _handleRemoveRule() {
       if (this.rule.value.added) {
-        this.dispatchEvent(new CustomEvent('added-rule-removed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'added-rule-removed', {bubbles: true, composed: true}));
       }
       this._deleted = true;
       this.rule.value.deleted = true;
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _handleUndoRemove() {
@@ -237,7 +238,8 @@
       if (!this._originalRuleValues) { return; }
       this.rule.value.modified = true;
       // Allows overall access page to know a change has been made.
-      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('access-modified', {bubbles: true, composed: true}));
     },
 
     _setOriginalRuleValues(value) {
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 ecc7532..5d15d60 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
@@ -214,6 +214,7 @@
       this.set('change.reviewed', newVal);
       this.dispatchEvent(new CustomEvent('toggle-reviewed', {
         bubbles: true,
+        composed: true,
         detail: {change: this.change, reviewed: newVal},
       }));
     },
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
index 42d7bd7..b9df583b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
@@ -29,7 +29,8 @@
 
     _handleCreateTap(e) {
       e.preventDefault();
-      this.dispatchEvent(new CustomEvent('create-tap', {bubbles: true}));
+      this.dispatchEvent(
+          new CustomEvent('create-tap', {bubbles: true, composed: true}));
     },
   });
 })();
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 fc0b633..147d1f2 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
@@ -118,8 +118,8 @@
 
     _inputTextChanged(text) {
       if (text.length && this.allowAnyInput) {
-        this.dispatchEvent(new CustomEvent('account-text-changed',
-            {bubbles: true}));
+        this.dispatchEvent(new CustomEvent(
+            'account-text-changed', {bubbles: true, composed: true}));
       }
     },
 
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 533ae09..c10f3d5 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
@@ -123,8 +123,11 @@
           // Repopulate the input with what the user tried to enter and have
           // a toast tell them why they can't enter it.
           this.$.entry.setText(reviewer);
-          this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message: VALID_EMAIL_ALERT}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: VALID_EMAIL_ALERT},
+            bubbles: true,
+            composed: true,
+          }));
           return false;
         } else {
           const account = {email: reviewer, _pendingAdd: true};
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 8ca8146..60f0dc2 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
@@ -221,8 +221,8 @@
             this._settingTopic = false;
             this.set(['change', 'topic'], newTopic);
             if (newTopic !== lastTopic) {
-              this.dispatchEvent(
-                  new CustomEvent('topic-changed', {bubbles: true}));
+              this.dispatchEvent(new CustomEvent(
+                  'topic-changed', {bubbles: true, composed: true}));
             }
           });
     },
@@ -246,8 +246,8 @@
           this.change._number, {add: [newHashtag]}).then(newHashtag => {
             this.set(['change', 'hashtags'], newHashtag);
             if (newHashtag !== lastHashtag) {
-              this.dispatchEvent(
-                  new CustomEvent('hashtag-changed', {bubbles: true}));
+              this.dispatchEvent(new CustomEvent(
+                  'hashtag-changed', {bubbles: true, composed: true}));
             }
           });
     },
@@ -378,7 +378,7 @@
         target.disabled = false;
         this.set(['change', 'topic'], '');
         this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true}));
+            new CustomEvent('topic-changed', {bubbles: true, composed: true}));
       }).catch(err => {
         target.disabled = false;
         return;
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 4fd19fb..d3ad323 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
@@ -1367,7 +1367,8 @@
       // Resolves when the loading flag is set to false, meaning that some
       // change content may start appearing.
       const loadingFlagSet = detailCompletes
-          .then(() => { this._loading = false; });
+          .then(() => { this._loading = false; })
+          .then(() => { this.$.reporting.changeDisplayed(); });
 
       // Resolves when the project config has loaded.
       const projectConfigLoaded = detailCompletes
@@ -1439,8 +1440,7 @@
         this.$.reporting.changeFullyLoaded();
       });
 
-      return coreDataPromise
-          .then(() => { this.$.reporting.changeDisplayed(); });
+      return coreDataPromise;
     },
 
     /**
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 a5adba7..89d799b 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
@@ -1599,32 +1599,44 @@
       sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
 
       // Delete
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.DELETE.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.DELETE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openDeleteDialog.called);
       assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
 
       // Restore
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.RESTORE.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RESTORE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openRestoreDialog.called);
       assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
 
       // Rename
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.RENAME.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RENAME.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(controls.openRenameDialog.called);
       assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
 
       // Open
-      fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.OPEN.id, path: 'foo'}, bubbles: true}));
+      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+        detail: {action: Actions.OPEN.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      }));
       flushAsynchronousOperations();
 
       assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index d9b961f..fe28a1d 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -133,7 +133,8 @@
       const name = e.target.selectedItem.name;
       const value = e.target.selectedItem.getAttribute('value');
       this.dispatchEvent(new CustomEvent(
-        'labels-changed', {detail: {name, value}, bubbles: true}));
+          'labels-changed',
+          {detail: {name, value}, bubbles: true, composed: true}));
     },
 
     _computeAnyPermittedLabelValues(permittedLabels, label) {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
index 7dd4c76..bb7c7d8 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
@@ -28,6 +28,9 @@
         text-align: center;
         width: 100%;
       }
+      gr-label-score-row.no-access {
+        display: var(--label-no-access-display, initial);
+      }
       @media only screen and (max-width: 25em) {
         :host {
           text-align: center;
@@ -36,6 +39,7 @@
     </style>
     <template is="dom-repeat" items="[[_labels]]" as="label">
       <gr-label-score-row
+          class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
           label="[[label]]"
           name="[[label.name]]"
           labels="[[change.labels]]"
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
index eaf39bc..f18680e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -115,5 +115,19 @@
     _changeIsMerged(changeStatus) {
       return changeStatus === 'MERGED';
     },
+
+    /**
+     * @param label {string|undefined}
+     * @param permittedLabels {Object|undefined}
+     * @return {string}
+     */
+    _computeLabelAccessClass(label, permittedLabels) {
+      if (label == null || permittedLabels == null) {
+        return '';
+      }
+
+      return permittedLabels.hasOwnProperty(label) &&
+        permittedLabels[label].length ? 'access' : 'no-access';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
index 24529ec..a2629b6 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -135,6 +135,25 @@
       });
     });
 
+    test('_computeLabelAccessClass undefined case', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass(undefined, undefined), '');
+      assert.strictEqual(
+          element._computeLabelAccessClass('', undefined), '');
+      assert.strictEqual(
+          element._computeLabelAccessClass(undefined, {}), '');
+    });
+
+    test('_computeLabelAccessClass has access', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
+    });
+
+    test('_computeLabelAccessClass no access', () => {
+      assert.strictEqual(
+          element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
+    });
+
     test('changes in label score are reflected in _labels', () => {
       element.change = {
         _number: '123',
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 837ffe0..a2ec28c 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -228,6 +228,7 @@
       e.preventDefault();
       this.dispatchEvent(new CustomEvent('message-anchor-tap', {
         bubbles: true,
+        composed: true,
         detail: {id: this.message.id},
       }));
     },
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index eb06b0f..7101249 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -150,7 +150,8 @@
           flush(() => {
             const textarea = element.$.textarea.getNativeTextarea();
             textarea.value = 'LGTM';
-            textarea.dispatchEvent(new CustomEvent('input', {bubbles: true}));
+            textarea.dispatchEvent(new CustomEvent(
+                'input', {bubbles: true, composed: true}));
             const labelScoreRows = Polymer.dom(element.$.labelScores.root)
                 .querySelector('gr-label-score-row[name="Code-Review"]');
             const selectedBtn = Polymer.dom(labelScoreRows.root)
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 8ca9128..f775e1c 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
@@ -749,6 +749,7 @@
       if (this._sendDisabled) {
         this.dispatchEvent(new CustomEvent('show-alert', {
           bubbles: true,
+          composed: true,
           detail: {message: EMPTY_REPLY_MESSAGE},
         }));
         return;
@@ -756,6 +757,13 @@
       return this.send(this._includeComments, this.canBeStarted)
           .then(keepReviewers => {
             this._purgeReviewersPendingRemove(false, keepReviewers);
+          })
+          .catch(err => {
+            this.dispatchEvent(new CustomEvent('show-error', {
+              bubbles: true,
+              composed: true,
+              detail: {message: `Error submitting review ${err}`},
+            }));
           });
     },
 
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 f9108c7..aec3491 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
@@ -415,7 +415,7 @@
       assert.isTrue(element.$$('#ccs').allowAnyInput);
       assert.isFalse(element.$$('#reviewers').allowAnyInput);
       element.$$('#ccs').dispatchEvent(new CustomEvent('account-text-changed',
-          {bubbles: true}));
+          {bubbles: true, composed: true}));
       assert.isTrue(element._reviewersMutated);
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
new file mode 100644
index 0000000..56a6fb9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
@@ -0,0 +1,24 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-coverage-layer">
+  <template>
+  </template>
+  <script src="../gr-diff-highlight/gr-annotation.js"></script>
+  <script src="gr-coverage-layer.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
new file mode 100644
index 0000000..e8d6900
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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';
+
+  /** @enum {string} */
+  Gerrit.CoverageType = {
+    /**
+     * start_character and end_character of the range will be ignored for this
+     * type.
+     */
+    COVERED: 'COVERED',
+    /**
+     * start_character and end_character of the range will be ignored for this
+     * type.
+     */
+    NOT_COVERED: 'NOT_COVERED',
+    PARTIALLY_COVERED: 'PARTIALLY_COVERED',
+    /**
+     * You don't have to use this. If there is no coverage information for a
+     * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+     * end_character of the range will be ignored for this type.
+     */
+    NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
+  };
+
+  const TOOLTIP_MAP = new Map([
+    [Gerrit.CoverageType.COVERED, 'Covered by tests.'],
+    [Gerrit.CoverageType.NOT_COVERED, 'Not covered by tests.'],
+    [Gerrit.CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
+    [Gerrit.CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
+  ]);
+
+  /**
+   * @typedef {{
+   *   side: string,
+   *   type: Gerrit.CoverageType,
+   *   code_range: Gerrit.Range,
+   * }}
+   */
+  Gerrit.CoverageRange;
+
+  Polymer({
+    is: 'gr-coverage-layer',
+
+    properties: {
+      /**
+       * Must be sorted by code_range.start_line.
+       * Must only contain ranges that match the side.
+       *
+       * @type {!Array<!Gerrit.CoverageRange>}
+       */
+      coverageRanges: Array,
+      side: String,
+
+      /**
+       * We keep track of the line number from the previous annotate() call,
+       * and also of the index of the coverage range that had matched.
+       * annotate() calls are coming in with increasing line numbers and
+       * coverage ranges are sorted by line number. So this is a very simple
+       * and efficient way for finding the coverage range that matches a given
+       * line number.
+       */
+      _lineNumber: {
+        type: Number,
+        value: 0,
+      },
+      _index: {
+        type: Number,
+        value: 0,
+      },
+    },
+
+    /**
+     * Layer method to add annotations to a line.
+     *
+     * @param {!HTMLElement} el Not used for this layer.
+     * @param {!HTMLElement} lineNumberEl The <td> element with the line number.
+     * @param {!Object} line Not used for this layer.
+     */
+    annotate(el, lineNumberEl, line) {
+      if (!lineNumberEl || !lineNumberEl.classList.contains(this.side)) {
+        return;
+      }
+      const elementLineNumber = parseInt(
+          lineNumberEl.getAttribute('data-value'), 10);
+      if (!elementLineNumber || elementLineNumber < 1) return;
+
+      // If the line number is smaller than before, then we have to reset our
+      // algorithm and start searching the coverage ranges from the beginning.
+      // That happens for example when you expand diff sections.
+      if (elementLineNumber < this._lineNumber) {
+        this._index = 0;
+      }
+      this._lineNumber = elementLineNumber;
+
+      // We simply loop through all the coverage ranges until we find one that
+      // matches the line number.
+      while (this._index < this.coverageRanges.length) {
+        const coverageRange = this.coverageRanges[this._index];
+
+        // If the line number has moved past the current coverage range, then
+        // try the next coverage range.
+        if (this._lineNumber > coverageRange.code_range.end_line) {
+          this._index++;
+          continue;
+        }
+
+        // If the line number has not reached the next coverage range (and the
+        // range before also did not match), then this line has not been
+        // instrumented. Nothing to do for this line.
+        if (this._lineNumber < coverageRange.code_range.start_line) {
+          return;
+        }
+
+        // The line number is within the current coverage range. Style it!
+        lineNumberEl.classList.add(coverageRange.type);
+        lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type);
+        return;
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
new file mode 100644
index 0000000..edd88a2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
@@ -0,0 +1,138 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-coverage-layer</title>
+
+<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="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-coverage-layer.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-coverage-layer></gr-coverage-layer>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-coverage-layer', () => {
+    let element;
+
+    setup(() => {
+      const initialCoverageRanges = [
+        {
+          type: 'COVERED',
+          side: 'right',
+          code_range: {
+            start_line: 1,
+            end_line: 2,
+          },
+        },
+        {
+          type: 'NOT_COVERED',
+          side: 'right',
+          code_range: {
+            start_line: 3,
+            end_line: 4,
+          },
+        },
+        {
+          type: 'PARTIALLY_COVERED',
+          side: 'right',
+          code_range: {
+            start_line: 5,
+            end_line: 6,
+          },
+        },
+        {
+          type: 'NOT_INSTRUMENTED',
+          side: 'right',
+          code_range: {
+            start_line: 8,
+            end_line: 9,
+          },
+        },
+      ];
+
+      element = fixture('basic');
+      element.coverageRanges = initialCoverageRanges;
+      element.side = 'right';
+    });
+
+    suite('annotate', () => {
+      function createLine(lineNumber) {
+        lineEl = document.createElement('div');
+        lineEl.setAttribute('data-side', 'right');
+        lineEl.setAttribute('data-value', lineNumber);
+        lineEl.className = 'right';
+        return lineEl;
+      }
+
+      function checkLine(lineNumber, className, opt_negated) {
+        const line = createLine(lineNumber);
+        element.annotate(undefined, line, undefined);
+        let contains = line.classList.contains(className);
+        if (opt_negated) contains = !contains;
+        assert.isTrue(contains);
+      }
+
+      test('line 1-2 are covered', () => {
+        checkLine(1, 'COVERED');
+        checkLine(2, 'COVERED');
+      });
+
+      test('line 3-4 are not covered', () => {
+        checkLine(3, 'NOT_COVERED');
+        checkLine(4, 'NOT_COVERED');
+      });
+
+      test('line 5-6 are partially covered', () => {
+        checkLine(5, 'PARTIALLY_COVERED');
+        checkLine(6, 'PARTIALLY_COVERED');
+      });
+
+      test('line 7 is implicitly not instrumented', () => {
+        checkLine(7, 'COVERED', true);
+        checkLine(7, 'NOT_COVERED', true);
+        checkLine(7, 'PARTIALLY_COVERED', true);
+        checkLine(7, 'NOT_INSTRUMENTED', true);
+      });
+
+      test('line 8-9 are not instrumented', () => {
+        checkLine(8, 'NOT_INSTRUMENTED');
+        checkLine(9, 'NOT_INSTRUMENTED');
+      });
+
+      test('coverage correct, if annotate is called out of order', () => {
+        checkLine(8, 'NOT_INSTRUMENTED');
+        checkLine(1, 'COVERED');
+        checkLine(5, 'PARTIALLY_COVERED');
+        checkLine(3, 'NOT_COVERED');
+        checkLine(6, 'PARTIALLY_COVERED');
+        checkLine(4, 'NOT_COVERED');
+        checkLine(9, 'NOT_INSTRUMENTED');
+        checkLine(2, 'COVERED');
+      });
+    });
+  });
+</script>
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 b5f21b6..42fb567 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
@@ -16,6 +16,7 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="../gr-coverage-layer/gr-coverage-layer.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">
@@ -31,6 +32,14 @@
     <gr-syntax-layer
         id="syntaxLayer"
         diff="[[diff]]"></gr-syntax-layer>
+    <gr-coverage-layer
+        id="coverageLayerLeft"
+        coverage-ranges="[[_leftCoverageRanges]]"
+        side="left"></gr-coverage-layer>
+    <gr-coverage-layer
+        id="coverageLayerRight"
+        coverage-ranges="[[_rightCoverageRanges]]"
+        side="right"></gr-coverage-layer>
     <gr-diff-processor
         id="processor"
         groups="{{_groups}}"></gr-diff-processor>
@@ -108,6 +117,19 @@
             type: Array,
             value: () => [],
           },
+          /** @type {!Array<!Gerrit.CoverageRange>} */
+          coverageRanges: {
+            type: Array,
+            value: () => [],
+          },
+          _leftCoverageRanges: {
+            type: Array,
+            computed: '_computeLeftCoverageRanges(coverageRanges)',
+          },
+          _rightCoverageRanges: {
+            type: Array,
+            computed: '_computeRightCoverageRanges(coverageRanges)',
+          },
           /**
            * The promise last returned from `render()` while the asynchronous
            * rendering is running - `null` otherwise. Provides a `cancel()`
@@ -125,6 +147,14 @@
           '_groupsChanged(_groups.splices)',
         ],
 
+        _computeLeftCoverageRanges(coverageRanges) {
+          return coverageRanges.filter(range => range && range.side === 'left');
+        },
+
+        _computeRightCoverageRanges(coverageRanges) {
+          return coverageRanges.filter(range => range && range.side === 'right');
+        },
+
         render(keyLocations, prefs) {
           // Setting up annotation layers must happen after plugins are
           // installed, and |render| satisfies the requirement, however,
@@ -149,7 +179,8 @@
 
           const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-          this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
+          this.dispatchEvent(new CustomEvent(
+              'render-start', {bubbles: true, composed: true}));
           this._cancelableRenderPromise = util.makeCancelable(
               this.$.processor.process(this.diff.content, isBinary)
                   .then(() => {
@@ -157,7 +188,7 @@
                       this._builder.renderDiff();
                     }
                     this.dispatchEvent(new CustomEvent('render-content',
-                        {bubbles: true}));
+                        {bubbles: true, composed: true}));
 
                     if (this._diffTooLargeForSyntax()) {
                       this.$.syntaxLayer.enabled = false;
@@ -166,8 +197,8 @@
                     return this.$.syntaxLayer.process();
                   })
                   .then(() => {
-                    this.dispatchEvent(
-                        new CustomEvent('render-syntax', {bubbles: true}));
+                    this.dispatchEvent(new CustomEvent(
+                        'render-syntax', {bubbles: true, composed: true}));
                   }));
           return this._cancelableRenderPromise
               .finally(() => { this._cancelableRenderPromise = null; })
@@ -184,6 +215,8 @@
             this._createIntralineLayer(),
             this._createTabIndicatorLayer(),
             this.$.rangeLayer,
+            this.$.coverageLayerLeft,
+            this.$.coverageLayerRight,
           ];
 
           // Get layers from plugins (if any).
@@ -281,7 +314,7 @@
           this.dispatchEvent(new CustomEvent('show-alert', {
             detail: {
               message,
-            }, bubbles: true}));
+            }, bubbles: true, composed: true}));
           throw Error(`Invalid preference value: ${pref}`);
         },
 
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 c8d91df..fb9b726 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
@@ -243,8 +243,8 @@
     }
 
     const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-    ctxLine.contextGroup =
-        new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
+    ctxLine.contextGroups =
+        [new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines)];
     groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
         [ctxLine]));
 
@@ -254,13 +254,15 @@
   };
 
   GrDiffBuilder.prototype._createContextControl = function(section, line) {
-    if (!line.contextGroup || !line.contextGroup.lines.length) {
-      return null;
-    }
+    if (!line.contextGroups) return null;
+
+    const numLines = line.contextGroups.reduce(
+        (sum, contextGroup) => sum + contextGroup.lines.length, 0);
+
+    if (numLines === 0) return null;
 
     const td = this._createElement('td');
-    const showPartialLinks =
-        line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT;
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
 
     if (showPartialLinks) {
       td.appendChild(this._createContextButton(
@@ -281,7 +283,7 @@
   };
 
   GrDiffBuilder.prototype._createContextButton = function(type, section, line) {
-    const contextLines = line.contextGroup.lines;
+    const contextLines = line.contextGroups[0].lines;
     const context = PARTIAL_CONTEXT_AMOUNT;
 
     const button = this._createElement('gr-button', 'showContext');
@@ -294,7 +296,7 @@
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
       text = 'Show ' + contextLines.length + ' common line';
       if (contextLines.length > 1) { text += 's'; }
-      groups.push(line.contextGroup);
+      groups.push(...line.contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
       text = '+' + context + '↑';
       this._insertContextGroups(groups, contextLines,
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 481db57..fa54756 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
@@ -89,11 +89,11 @@
 
     test('context control buttons', () => {
       const section = {};
-      const line = {contextGroup: {lines: []}};
+      const line = {contextGroups: [{lines: []}]};
 
       // Create 10 lines.
       for (let i = 0; i < 10; i++) {
-        line.contextGroup.lines.push('lorem upsum');
+        line.contextGroups[0].lines.push('lorem upsum');
       }
 
       // Does not include +10 buttons when there are fewer than 11 lines.
@@ -104,7 +104,7 @@
       assert.equal(Polymer.dom(buttons[0]).textContent, 'Show 10 common lines');
 
       // Add another line.
-      line.contextGroup.lines.push('lorem upsum');
+      line.contextGroups[0].lines.push('lorem upsum');
 
       // Includes +10 buttons when there are at least 11 lines.
       td = builder._createContextControl(section, line);
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 9ec2b29..191d1d2 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
@@ -179,8 +179,8 @@
         element.commentRanges = [{side: 'right'}];
 
         sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(
-            new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
         assert.isFalse(element.set.called);
       });
 
@@ -204,8 +204,8 @@
         }}];
 
         sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(
-            new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
         assert.isTrue(element.set.called);
         const args = element.set.lastCall.args;
         assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
@@ -221,8 +221,8 @@
         element.commentRanges = [{side: 'right'}];
 
         sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(
-            new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseleave', {bubbles: true, composed: true}));
         assert.isFalse(element.set.called);
       });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
index 4c310b9..05f48d8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -20,6 +20,7 @@
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
 
 <dom-module id="gr-diff-host">
@@ -45,8 +46,10 @@
         error-message="[[_errorMessage]]"
         base-image="[[_baseImage]]"
         revision-image=[[_revisionImage]]
+        coverage-ranges="[[_coverageRanges]]"
         blame="[[_blame]]"
         diff="[[_diff]]"></gr-diff>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting" category="diff"></gr-reporting>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 94f6ce9..d47a61d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -110,7 +110,9 @@
       commitRange: Object,
       filesWeblinks: {
         type: Object,
-        value() { return {}; },
+        value() {
+          return {};
+        },
         notify: true,
       },
       hidden: {
@@ -186,6 +188,16 @@
         value: null,
       },
 
+      /**
+       * TODO(brohlfs): Replace Object type by Gerrit.CoverageRange.
+       *
+       * @type {!Array<!Object>}
+       */
+      _coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
+
       _loadedWhitespaceLevel: String,
 
       _parentIndex: {
@@ -240,6 +252,21 @@
       this._errorMessage = null;
       const whitespaceLevel = this._getIgnoreWhitespace();
 
+      this._coverageRanges = [];
+      const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
+      this.$.jsAPI.getCoverageRanges(changeNum, path, basePatchNum, patchNum).
+          then(coverageRanges => {
+            if (changeNum !== this.changeNum ||
+                path !== this.path ||
+                basePatchNum !== this.patchRange.basePatchNum ||
+                patchNum !== this.patchRange.patchNum) {
+              return;
+            }
+            this._coverageRanges = coverageRanges;
+          }).catch(err => {
+            console.warn('Loading coverage ranges failed: ', err);
+          });
+
       const diffRequest = this._getDiff()
           .then(diff => {
             this._loadedWhitespaceLevel = whitespaceLevel;
@@ -284,7 +311,9 @@
     },
 
     _getFilesWeblinks(diff) {
-      if (!this.commitRange) { return {}; }
+      if (!this.commitRange) {
+        return {};
+      }
       return {
         meta_a: Gerrit.Nav.getFileWebLinks(
             this.projectName, this.commitRange.baseCommit, this.path,
@@ -410,7 +439,9 @@
      * Report info about the diff response.
      */
     _reportDiff(diff) {
-      if (!diff || !diff.content) { return; }
+      if (!diff || !diff.content) {
+        return;
+      }
 
       // Count the delta lines stemming from normal deltas, and from
       // due_to_rebase deltas.
@@ -638,7 +669,7 @@
      * @param {!Gerrit.Range=} range
      * @return {?Node}
      */
-    _getThreadEl(lineNum, commentSide, range=undefined) {
+    _getThreadEl(lineNum, commentSide, range = undefined) {
       let line;
       if (commentSide === GrDiffBuilder.Side.LEFT) {
         line = {beforeNumber: lineNum};
@@ -753,7 +784,8 @@
       return this.prefs.ignore_whitespace;
     },
 
-    _whitespaceChanged(preferredWhitespaceLevel, loadedWhitespaceLevel,
+    _whitespaceChanged(
+        preferredWhitespaceLevel, loadedWhitespaceLevel,
         noRenderOnPrefsChange) {
       if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
           !noRenderOnPrefsChange) {
@@ -806,8 +838,8 @@
     },
 
     _handleCommentSaveOrDiscard() {
-      this.dispatchEvent(new CustomEvent('diff-comments-modified',
-          {bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'diff-comments-modified', {bubbles: true, composed: true}));
     },
 
     _removeComment(comment) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index 7466ade..d27a0e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -269,7 +269,7 @@
     suite('render reporting', () => {
       test('starts total and content timer on render-start', done => {
         element.dispatchEvent(
-            new CustomEvent('render-start', {bubbles: true}));
+            new CustomEvent('render-start', {bubbles: true, composed: true}));
         assert.isTrue(element.$.reporting.time.calledWithExactly(
             'Diff Total Render'));
         assert.isTrue(element.$.reporting.time.calledWithExactly(
@@ -279,7 +279,7 @@
 
       test('ends content and starts syntax timer on render-content', done => {
         element.dispatchEvent(
-            new CustomEvent('render-content', {bubbles: true}));
+            new CustomEvent('render-content', {bubbles: true, composed: true}));
         assert.isTrue(element.$.reporting.time.calledWithExactly(
             'Diff Syntax Render'));
         assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
@@ -289,7 +289,7 @@
 
       test('ends total and syntax timer on render-syntax', done => {
         element.dispatchEvent(
-            new CustomEvent('render-syntax', {bubbles: true}));
+            new CustomEvent('render-syntax', {bubbles: true, composed: true}));
         assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
             'Diff Total Render'));
         assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
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 a1af8e1..800e9b3 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
@@ -24,12 +24,6 @@
     RIGHT: 'right',
   };
 
-  const DiffGroupType = {
-    ADDED: 'b',
-    BOTH: 'ab',
-    REMOVED: 'a',
-  };
-
   const DiffHighlights = {
     ADDED: 'edit_b',
     REMOVED: 'edit_a',
@@ -168,7 +162,8 @@
               }
 
               // Process the next section and incorporate the result.
-              const result = this._processNext(state, content);
+              const result = this._processNext(
+                  state, content[state.sectionIndex], content.length);
               for (const group of result.groups) {
                 this.push('groups', group);
                 currentBatch += group.lines.length;
@@ -207,66 +202,101 @@
 
     /**
      * Process the next section of the diff.
+     *
+     * @param {!Object} state
+     * @param {!Object} section
+     * @param {number} numSections
      */
-    _processNext(state, content) {
-      const section = content[state.sectionIndex];
-
-      const rows = {
-        both: section[DiffGroupType.BOTH] || null,
-        added: section[DiffGroupType.ADDED] || null,
-        removed: section[DiffGroupType.REMOVED] || null,
+    _processNext(state, section, numSections) {
+      const lines = this._linesFromSection(
+          section, state.lineNums.left + 1, state.lineNums.right + 1);
+      const lineDelta = {
+        left: section.ab ? section.ab.length : section.a ? section.a.length : 0,
+        right: section.ab ? section.ab.length :
+            section.b ? section.b.length : 0,
       };
-
-      const highlights = {
-        added: section[DiffHighlights.ADDED] || null,
-        removed: section[DiffHighlights.REMOVED] || null,
-      };
-
-      if (rows.both) { // If it's a shared section.
+      let groups;
+      if (section.ab) { // If it's a shared section.
         let sectionEnd = null;
         if (state.sectionIndex === 0) {
           sectionEnd = 'first';
-        } else if (state.sectionIndex === content.length - 1) {
+        } else if (state.sectionIndex === numSections - 1) {
           sectionEnd = 'last';
         }
-
-        const sharedGroups = this._sharedGroupsFromRows(
-            rows.both,
-            content.length > 1 ? this.context : WHOLE_FILE,
+        groups = this._sharedGroupsFromLines(
+            lines,
+            lineDelta.left,
+            numSections > 1 ? this.context : WHOLE_FILE,
             state.lineNums.left,
             state.lineNums.right,
             sectionEnd);
-
-        return {
-          lineDelta: {
-            left: rows.both.length,
-            right: rows.both.length,
-          },
-          groups: sharedGroups,
-        };
       } else { // Otherwise it's a delta section.
-        const deltaGroup = this._deltaGroupFromRows(
-            rows.added,
-            rows.removed,
-            state.lineNums.left,
-            state.lineNums.right,
-            highlights);
+        const deltaGroup = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
         deltaGroup.dueToRebase = section.due_to_rebase;
-
-        return {
-          lineDelta: {
-            left: rows.removed ? rows.removed.length : 0,
-            right: rows.added ? rows.added.length : 0,
-          },
-          groups: [deltaGroup],
-        };
+        groups = [deltaGroup];
       }
+      return {lineDelta, groups};
     },
 
+    _linesFromSection(section, offsetLeft, offsetRight) {
+      const lines = [];
+      if (section.ab) {
+        lines.push(...section.ab.map((row, i) =>
+          this._lineFromRow(
+              GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i)));
+      }
+      if (section.a) {
+        lines.push(...this._deltaLinesFromRows(
+            GrDiffLine.Type.REMOVE, section.a, offsetLeft,
+            section[DiffHighlights.REMOVED]));
+      }
+      if (section.b) {
+        lines.push(...this._deltaLinesFromRows(
+            GrDiffLine.Type.ADD, section.b, offsetRight,
+            section[DiffHighlights.ADDED]));
+      }
+      return lines;
+    },
+
+    /**
+     * @return {!Array<!Object>} Array of GrDiffLines
+     */
+    _deltaLinesFromRows(lineType, rows, offset, opt_highlights) {
+      // Normalize highlights if they have been passed.
+      if (opt_highlights) {
+        opt_highlights = this._normalizeIntralineHighlights(rows,
+            opt_highlights);
+      }
+      return rows.map((row, i) =>
+          this._lineFromRow(lineType, offset, offset, row, i, opt_highlights));
+    },
+
+    /**
+     * @param {string} type (GrDiffLine.Type)
+     * @param {number} offsetLeft
+     * @param {number} offsetRight
+     * @param {string} row
+     * @param {number} i
+     * @param {!Array<!Object>=} opt_highlights
+     * @return {!Object} (GrDiffLine)
+     */
+    _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
+      const line = new GrDiffLine(type);
+      line.text = row;
+      if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
+      if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
+      if (opt_highlights) {
+        line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
+      }
+      return line;
+    },
+
+
     /**
      * Take rows of a shared diff section and produce an array of corresponding
      * (potentially collapsed) groups.
-     * @param {!Array<string>} rows
+     * @param {!Array<string>} lines
+     * @param {number} numLines
      * @param {number} context
      * @param {number} startLineNumLeft
      * @param {number} startLineNumRight
@@ -275,44 +305,40 @@
      *     'last' and null respectively.
      * @return {!Array<!Object>} Array of GrDiffGroup
      */
-    _sharedGroupsFromRows(rows, context, startLineNumLeft,
+    _sharedGroupsFromLines(lines, numLines, context, startLineNumLeft,
         startLineNumRight, opt_sectionEnd) {
-      const result = [];
-      const lines = [];
-      let line;
-
-      // Map each row to a GrDiffLine.
-      for (let i = 0; i < rows.length; i++) {
-        line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.text = rows[i];
-        line.beforeNumber = ++startLineNumLeft;
-        line.afterNumber = ++startLineNumRight;
-        lines.push(line);
-      }
-
       // Find the hidden range based on the user's context preference. If this
       // is the first or the last section of the diff, make sure the collapsed
       // part of the section extends to the edge of the file.
-      const hiddenRange = [context, rows.length - context];
-      if (opt_sectionEnd === 'first') {
-        hiddenRange[0] = 0;
-      } else if (opt_sectionEnd === 'last') {
-        hiddenRange[1] = rows.length;
-      }
+      const hiddenRangeStart = opt_sectionEnd === 'first' ? 0 : context;
+      const hiddenRangeEnd = opt_sectionEnd === 'last' ?
+          numLines : numLines - context;
 
+      const result = [];
       // If there is a range to hide.
-      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 1) {
-        const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-        const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-        const linesAfterCtx = lines.slice(hiddenRange[1]);
+      if (context !== WHOLE_FILE && hiddenRangeEnd - hiddenRangeStart > 1) {
+        const linesBeforeCtx = [];
+        const hiddenLines = [];
+        const linesAfterCtx = [];
+        for (const line of lines) {
+          // In the case there are no changes, these are the same.
+          // In the case of ignored whitespace changes, either only one is set,
+          // or the are the same.
+          const lineOffset = line.beforeNumber ?
+              line.beforeNumber - startLineNumLeft - 1 :
+              line.afterNumber - startLineNumRight - 1;
+          if (lineOffset < hiddenRangeStart) linesBeforeCtx.push(line);
+          else if (hiddenRangeEnd <= lineOffset) linesAfterCtx.push(line);
+          else hiddenLines.push(line);
+        }
 
         if (linesBeforeCtx.length > 0) {
           result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
         }
 
         const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-        ctxLine.contextGroup =
-            new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
+        ctxLine.contextGroups =
+            [new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines)];
         result.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
             [ctxLine]));
 
@@ -326,58 +352,6 @@
       return result;
     },
 
-    /**
-     * 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
-     * @return {!Object} (Gr-Diff-Group)
-     */
-    _deltaGroupFromRows(rowsAdded, rowsRemoved, startLineNumLeft,
-        startLineNumRight, highlights) {
-      let lines = [];
-      if (rowsRemoved) {
-        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.REMOVE,
-            rowsRemoved, startLineNumLeft, highlights.removed));
-      }
-      if (rowsAdded) {
-        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.ADD,
-            rowsAdded, startLineNumRight, highlights.added));
-      }
-      return new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
-    },
-
-    /**
-     * @return {!Array<!Object>} Array of GrDiffLines
-     */
-    _deltaLinesFromRows(lineType, rows, startLineNum,
-        opt_highlights) {
-      // Normalize highlights if they have been passed.
-      if (opt_highlights) {
-        opt_highlights = this._normalizeIntralineHighlights(rows,
-            opt_highlights);
-      }
-
-      const lines = [];
-      let line;
-      for (let i = 0; i < rows.length; i++) {
-        line = new GrDiffLine(lineType);
-        line.text = rows[i];
-        if (lineType === GrDiffLine.Type.ADD) {
-          line.afterNumber = ++startLineNum;
-        } else {
-          line.beforeNumber = ++startLineNum;
-        }
-        if (opt_highlights) {
-          line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
-        }
-        lines.push(line);
-      }
-      return lines;
-    },
-
     _makeFileComments() {
       const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = GrDiffLine.FILE;
@@ -437,12 +411,12 @@
      */
     _splitUnchangedChunksWithComments(chunks) {
       const result = [];
-      let leftLineNum = 0;
-      let rightLineNum = 0;
+      let leftLineNum = 1;
+      let rightLineNum = 1;
 
       for (const chunk of chunks) {
         // If it isn't a common chunk, append it as-is and update line numbers.
-        if (!chunk.ab) {
+        if (!chunk.ab && !chunk.common) {
           if (chunk.a) {
             leftLineNum += chunk.a.length;
           }
@@ -453,40 +427,67 @@
           continue;
         }
 
-        let currentChunk = {ab: []};
-
-        // For each line in the common group.
-        for (const line of chunk.ab) {
-          leftLineNum++;
-          rightLineNum++;
-
-          // If this line should not be collapsed.
-          if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
-              this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
-            // If any lines have been accumulated into the chunk leading up to
-            // this non-collapse line, then add them as a chunk and start a new
-            // one.
-            if (currentChunk.ab && currentChunk.ab.length > 0) {
-              result.push(currentChunk);
-              currentChunk = {ab: []};
-            }
-
-            // Add the non-collapse line as its own chunk.
-            result.push({ab: [line]});
-          } else {
-            // Append the current line to the current chunk.
-            currentChunk.ab.push(line);
-          }
+        if (chunk.common && chunk.a.length != chunk.b.length) {
+          throw new Error(
+            'DiffContent with common=true must always have equal length');
         }
+        const numLines = chunk.ab ? chunk.ab.length : chunk.a.length;
+        const chunkEnds = this._findChunkEndsAtKeyLocations(
+            numLines, leftLineNum, rightLineNum);
+        leftLineNum += numLines;
+        rightLineNum += numLines;
 
-        if (currentChunk.ab && currentChunk.ab.length > 0) {
-          result.push(currentChunk);
+        if (chunk.ab) {
+          result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
+              .map(lines => Object.assign({}, chunk, {ab: lines})));
+        } else if (chunk.common) {
+          const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
+          const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
+          result.push(...aChunks.map((lines, i) =>
+              Object.assign({}, chunk, {a: lines, b: bChunks[i]})));
         }
       }
 
       return result;
     },
 
+    _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
+      const result = [];
+      let lastChunkEnd = 0;
+      for (let i=0; i<numLines; i++) {
+        // If this line should not be collapsed.
+        if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
+            this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
+          // If any lines have been accumulated into the chunk leading up to
+          // this non-collapse line, then add them as a chunk and start a new
+          // one.
+          if (i > lastChunkEnd) {
+            result.push(i);
+            lastChunkEnd = i;
+          }
+
+          // Add the non-collapse line as its own chunk.
+          result.push(i + 1);
+        }
+      }
+
+      if (numLines > lastChunkEnd) {
+        result.push(numLines);
+      }
+
+      return result;
+    },
+
+    _splitAtChunkEnds(lines, chunkEnds) {
+      const result = [];
+      let lastChunkEnd = 0;
+      for (const chunkEnd of chunkEnds) {
+        result.push(lines.slice(lastChunkEnd, chunkEnd));
+        lastChunkEnd = chunkEnd;
+      }
+      return result;
+    },
+
     /**
      * The `highlights` array consists of a list of <skip length, mark length>
      * pairs, where the skip length is the number of characters between the
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 186a49e..0e57dbf 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
@@ -173,9 +173,9 @@
           assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
 
           assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
-          for (const l of groups[1].lines[0].contextGroup.lines) {
+          assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
+          for (const l of groups[1].lines[0].contextGroups[0].lines) {
             assert.equal(l.text, content[0].ab[0]);
           }
 
@@ -209,9 +209,9 @@
           }
 
           assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
-          for (const l of groups[7].lines[0].contextGroup.lines) {
+          assert.instanceOf(groups[7].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[7].lines[0].contextGroups[0].lines.length, 90);
+          for (const l of groups[7].lines[0].contextGroups[0].lines) {
             assert.equal(l.text, content[4].ab[0]);
           }
 
@@ -254,9 +254,9 @@
           }
 
           assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
-          assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
-          for (const l of groups[3].lines[0].contextGroup.lines) {
+          assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 30);
+          for (const l of groups[3].lines[0].contextGroups[0].lines) {
             assert.equal(l.text, content[1].ab[0]);
           }
 
@@ -483,90 +483,113 @@
           rows = loremIpsum.split(' ');
         });
 
-        test('_sharedGroupsFromRows WHOLE_FILE', () => {
-          const context = WHOLE_FILE;
-          const lineNumbers = {left: 10, right: 100};
-          const result = element._sharedGroupsFromRows(
-              rows, context, lineNumbers.left, lineNumbers.right, null);
+        test('_processNext WHOLE_FILE', () => {
+          element.context = WHOLE_FILE;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            sectionIndex: 1,
+          };
+          const result = element._processNext(
+              state, {ab: rows}, 3);
 
           // Results in one, uncollapsed group with all rows.
-          assert.equal(result.length, 1);
-          assert.equal(result[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(result[0].lines.length, rows.length);
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(result.groups[0].lines.length, rows.length);
 
           // Line numbers are set correctly.
-          assert.equal(result[0].lines[0].beforeNumber, lineNumbers.left + 1);
-          assert.equal(result[0].lines[0].afterNumber, lineNumbers.right + 1);
+          assert.equal(
+              result.groups[0].lines[0].beforeNumber,
+              state.lineNums.left + 1);
+          assert.equal(
+              result.groups[0].lines[0].afterNumber,
+              state.lineNums.right + 1);
 
-          assert.equal(result[0].lines[rows.length - 1].beforeNumber,
-              lineNumbers.left + rows.length);
-          assert.equal(result[0].lines[rows.length - 1].afterNumber,
-              lineNumbers.right + rows.length);
+          assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
+              state.lineNums.left + rows.length);
+          assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
+              state.lineNums.right + rows.length);
         });
 
-        test('_sharedGroupsFromRows context', () => {
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, null);
-          const expectedCollapseSize = rows.length - 2 * context;
+        test('_processNext context', () => {
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            sectionIndex: 1,
+          };
+          const result = element._processNext(
+              state, {ab: rows}, 3);
+          const expectedCollapseSize = rows.length - 2 * element.context;
 
-          assert.equal(result.length, 3, 'Results in three groups');
+          assert.equal(result.groups.length, 3, 'Results in three groups');
 
           // The first and last are uncollapsed context, whereas the middle has
           // a single context-control line.
-          assert.equal(result[0].lines.length, context);
-          assert.equal(result[1].lines.length, 1);
-          assert.equal(result[2].lines.length, context);
+          assert.equal(result.groups[0].lines.length, element.context);
+          assert.equal(result.groups[1].lines.length, 1);
+          assert.equal(result.groups[2].lines.length, element.context);
 
           // The collapsed group has the hidden lines as its context group.
-          assert.equal(result[1].lines[0].contextGroup.lines.length,
+          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
               expectedCollapseSize);
         });
 
-        test('_sharedGroupsFromRows first', () => {
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, 'first');
-          const expectedCollapseSize = rows.length - context;
+        test('_processNext first', () => {
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            sectionIndex: 0,
+          };
+          const result = element._processNext(
+              state, {ab: rows}, 3);
+          const expectedCollapseSize = rows.length - element.context;
 
-          assert.equal(result.length, 2, 'Results in two groups');
+          assert.equal(result.groups.length, 2, 'Results in two groups');
 
           // Only the first group is collapsed.
-          assert.equal(result[0].lines.length, 1);
-          assert.equal(result[1].lines.length, context);
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.groups[1].lines.length, element.context);
 
           // The collapsed group has the hidden lines as its context group.
-          assert.equal(result[0].lines[0].contextGroup.lines.length,
+          assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
               expectedCollapseSize);
         });
 
-        test('_sharedGroupsFromRows few-rows', () => {
+        test('_processNext few-rows', () => {
           // Only ten rows.
           rows = rows.slice(0, 10);
-          const context = 10;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100, 'first');
+          element.context = 10;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            sectionIndex: 0,
+          };
+          const result = element._processNext(
+              state, {ab: rows}, 3);
 
           // 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);
+          assert.equal(result.groups.length, 1, 'Results in one group');
+          assert.equal(result.groups[0].lines.length, rows.length);
         });
 
-        test('_sharedGroupsFromRows no single line collapse', () => {
+        test('_processNext no single line collapse', () => {
           rows = rows.slice(0, 7);
-          const context = 3;
-          const result = element._sharedGroupsFromRows(
-              rows, context, 10, 100);
+          element.context = 3;
+          const state = {
+            lineNums: {left: 10, right: 100},
+            sectionIndex: 1,
+          };
+          const result = element._processNext(
+              state, {ab: rows}, 3);
 
           // 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);
+          assert.equal(result.groups.length, 1, 'Results in one group');
+          assert.equal(result.groups[0].lines.length, rows.length);
         });
 
         test('_deltaLinesFromRows', () => {
           const startLineNum = 10;
           let result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
-              startLineNum);
+              startLineNum + 1);
 
           assert.equal(result.length, rows.length);
           assert.equal(result[0].type, GrDiffLine.Type.ADD);
@@ -577,7 +600,7 @@
           assert.notOk(result[result.length - 1].beforeNumber);
 
           result = element._deltaLinesFromRows(GrDiffLine.Type.REMOVE, rows,
-              startLineNum);
+              startLineNum + 1);
 
           assert.equal(result.length, rows.length);
           assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 44bb52a..00b1023 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -31,7 +31,8 @@
   /** @type {number|string} */
   GrDiffLine.prototype.beforeNumber = 0;
 
-  GrDiffLine.prototype.contextGroup = null;
+  /** @type {?Array<Object>} ?Array<!GrDiffLine> */
+  GrDiffLine.prototype.contextGroups = null;
 
   GrDiffLine.prototype.text = '';
 
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 8cf0e3f..72fc1ee 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -271,6 +271,15 @@
       .newlineWarning.hidden {
         display: none;
       }
+      .lineNum.COVERED {
+         background-color: #E0F2F1;
+      }
+      .lineNum.NOT_COVERED {
+        background-color: #FFD1A4;
+      }
+      .lineNum.PARTIALLY_COVERED {
+        background: linear-gradient(to right bottom, #FFD1A4 0%, #FFD1A4 50%, #E0F2F1 50%, #E0F2F1 100%);
+      }
     </style>
     <style include="gr-syntax-theme"></style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
@@ -290,6 +299,7 @@
           <gr-diff-builder
               id="diffBuilder"
               comment-ranges="[[_commentRanges]]"
+              coverage-ranges="[[coverageRanges]]"
               project-name="[[projectName]]"
               diff="[[diff]]"
               diff-path="[[path]]"
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 24f167d..d7e193c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -167,6 +167,11 @@
         type: Array,
         value: () => [],
       },
+      /** @type {!Array<!Gerrit.CoverageRange>} */
+      coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -385,12 +390,12 @@
     _redispatchHoverEvents(addedThreadEls) {
       for (const threadEl of addedThreadEls) {
         threadEl.addEventListener('mouseenter', () => {
-          threadEl.dispatchEvent(
-              new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
+          threadEl.dispatchEvent(new CustomEvent(
+              'comment-thread-mouseenter', {bubbles: true, composed: true}));
         });
         threadEl.addEventListener('mouseleave', () => {
-          threadEl.dispatchEvent(
-              new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
+          threadEl.dispatchEvent(new CustomEvent(
+              'comment-thread-mouseleave', {bubbles: true, composed: true}));
         });
       }
     },
@@ -546,6 +551,7 @@
           this._getIsParentCommentByLineAndContent(lineEl, contentEl);
       this.dispatchEvent(new CustomEvent('create-comment', {
         bubbles: true,
+        composed: true,
         detail: {
           lineNum,
           side,
@@ -722,14 +728,16 @@
     _renderDiffTable() {
       this._unobserveIncrementalNodes();
       if (!this.prefs) {
-        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        this.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
         return;
       }
       if (this.prefs.context === -1 &&
           this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
           this._safetyBypass === null) {
         this._showWarning = true;
-        this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+        this.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
         return;
       }
 
@@ -739,7 +747,7 @@
       this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
           .then(() => {
             this.dispatchEvent(
-                new CustomEvent('render', {bubbles: true}));
+                new CustomEvent('render', {bubbles: true, composed: true}));
           });
     },
 
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 42f098b..762028a 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
@@ -762,7 +762,7 @@
             () => {
               Promise.resolve();
               element.$.diffBuilder.dispatchEvent(
-                  new CustomEvent('render', {bubbles: true}));
+                  new CustomEvent('render', {bubbles: true, composed: true}));
             });
         const mock = document.createElement('mock-diff-response');
         sandbox.stub(element.$.diffBuilder, 'getDiffLength').returns(10000);
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 e28142b..3c1f45f 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
@@ -188,6 +188,7 @@
               range.end = line.text.length;
               this.dispatchEvent(new CustomEvent('normalize-range', {
                 bubbles: true,
+                composed: true,
                 detail: {lineNum, side},
               }));
             }
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
index 01cd9df..bae838e 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
@@ -32,8 +32,9 @@
     },
 
     _handleTextareaInput(e) {
-      this.dispatchEvent(new CustomEvent('content-change',
-          {detail: {value: e.target.value}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'content-change',
+          {detail: {value: e.target.value}, bubbles: true, composed: true}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
index b79cd9d..423c493 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
@@ -50,7 +50,7 @@
       element.addEventListener('content-change', contentChangedHandler);
       textarea.value = 'test';
       textarea.dispatchEvent(new CustomEvent('input',
-          {target: textarea, bubbles: true}));
+          {target: textarea, bubbles: true, composed: true}));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
index 9407f18..9c59a9a 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -46,8 +46,9 @@
     },
 
     _dispatchFileAction(action, path) {
-      this.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action, path}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'file-action-tap',
+          {detail: {action, path}, bubbles: true, composed: true}));
     },
 
     _computeFileActions(actions) {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
index 8e108bb..ec6f110 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -104,7 +104,9 @@
     },
 
     _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.EDIT) { return; }
+      if (value.view !== Gerrit.Nav.View.EDIT) {
+        return;
+      }
 
       this._changeNum = value.changeNum;
       this._path = value.path;
@@ -134,7 +136,9 @@
 
     _handlePathChanged(e) {
       const path = e.detail;
-      if (path === this._path) { return Promise.resolve(); }
+      if (path === this._path) {
+        return Promise.resolve();
+      }
       return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
           this._path, path).then(res => {
             if (!res.ok) { return; }
@@ -158,8 +162,11 @@
           .then(res => {
             if (storedContent && storedContent.message &&
                 storedContent.message !== res.content) {
-              this.dispatchEvent(new CustomEvent('show-alert',
-                  {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message: RESTORED_MESSAGE},
+                bubbles: true,
+                composed: true,
+              }));
 
               this._newContent = storedContent.message;
             } else {
@@ -197,11 +204,14 @@
       this.dispatchEvent(new CustomEvent('show-alert', {
         detail: {message},
         bubbles: true,
+        composed: true,
       }));
     },
 
     _computeSaveDisabled(content, newContent, saving) {
-      if (saving) { return true; }
+      if (saving) {
+        return true;
+      }
       return content === newContent;
     },
 
@@ -224,7 +234,9 @@
 
     _handleSaveShortcut(e) {
       e.preventDefault();
-      if (!this._saveDisabled) { this._saveEdit(); }
+      if (!this._saveDisabled) {
+        this._saveEdit();
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
index 2f5332d..63f4314 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -121,7 +121,7 @@
     const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
     element._newContent = 'test';
     element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-      bubbles: true,
+      bubbles: true, composed: true,
       detail: {value: 'new content value'},
     }));
     element.flushDebouncer('store');
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
index a59b886..62bec12 100644
--- 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
@@ -66,7 +66,9 @@
 
     _getAgreementsUrl(configUrl) {
       let url;
-      if (!configUrl) { return ''; }
+      if (!configUrl) {
+        return '';
+      }
       if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
         url = configUrl;
       } else {
@@ -100,8 +102,8 @@
     },
 
     _createToast(message) {
-      this.dispatchEvent(new CustomEvent('show-alert',
-          {detail: {message}, bubbles: true}));
+      this.dispatchEvent(new CustomEvent(
+          'show-alert', {detail: {message}, bubbles: true, composed: true}));
     },
 
     _computeShowAgreementsClass(agreements) {
@@ -133,9 +135,13 @@
     // then hides the text box and submit button.
     _computeHideAgreementClass(name, config) {
       for (const key in config) {
-        if (!config.hasOwnProperty(key)) { continue; }
+        if (!config.hasOwnProperty(key)) {
+          continue;
+        }
         for (const prop in config[key]) {
-          if (!config[key].hasOwnProperty(prop)) { continue; }
+          if (!config[key].hasOwnProperty(prop)) {
+            continue;
+          }
           if (name === config[key].name &&
               !config[key].auto_verify_group) {
             return 'hideAgreementsTextBox';
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 1f76a68..d776b54 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
@@ -414,6 +414,7 @@
       this.dispatchEvent(new CustomEvent('show-alert', {
         detail: {message: RELOAD_MESSAGE},
         bubbles: true,
+        composed: true,
       }));
       this.async(() => {
         window.location.reload();
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 44f8c00..98fd94a 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
@@ -49,6 +49,7 @@
       this.set('change.starred', newVal);
       this.dispatchEvent(new CustomEvent('toggle-star', {
         bubbles: true,
+        composed: true,
         detail: {change: this.change, starred: newVal},
       }));
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index b5481a9..bf7df71 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -128,7 +128,8 @@
 
       _numPendingDraftRequests: {
         type: Object,
-        value: {number: 0}, // Intentional to share the object across instances.
+        value:
+            {number: 0}, // Intentional to share the object across instances.
       },
 
       _enableOverlay: {
@@ -230,7 +231,9 @@
      */
     save(opt_comment) {
       let comment = opt_comment;
-      if (!comment) { comment = this.comment; }
+      if (!comment) {
+        comment = this.comment;
+      }
 
       this.set('comment.message', this._messageText);
       this.editing = false;
@@ -340,7 +343,9 @@
 
     _computeSaveDisabled(draft, comment, resolved) {
       // If resolved state has changed and a msg exists, save should be enabled.
-      if (comment.unresolved === resolved && draft) { return false; }
+      if (comment.unresolved === resolved && draft) {
+        return false;
+      }
       return !draft || draft.trim() === '';
     },
 
@@ -376,7 +381,9 @@
     },
 
     _messageTextChanged(newValue, oldValue) {
-      if (!this.comment || (this.comment && this.comment.id)) { return; }
+      if (!this.comment || (this.comment && this.comment.id)) {
+        return;
+      }
 
       this.debounce('store', () => {
         const message = this._messageText;
@@ -400,9 +407,12 @@
 
     _handleAnchorTap(e) {
       e.preventDefault();
-      if (!this.comment.line) { return; }
+      if (!this.comment.line) {
+        return;
+      }
       this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
         bubbles: true,
+        composed: true,
         detail: {
           number: this.comment.line || FILE,
           side: this.side,
@@ -421,7 +431,9 @@
       e.preventDefault();
 
       // Ignore saves started while already saving.
-      if (this.disabled) { return; }
+      if (this.disabled) {
+        return;
+      }
       const timingLabel = this.comment.id ?
           REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
       const timer = this.$.reporting.getTimer(timingLabel);
@@ -450,6 +462,7 @@
     _handleFix() {
       this.dispatchEvent(new CustomEvent('create-fix-comment', {
         bubbles: true,
+        composed: true,
         detail: this._getEventPayload(),
       }));
     },
@@ -512,7 +525,9 @@
     },
 
     _getSavingMessage(numPending) {
-      if (numPending === 0) { return SAVED_MESSAGE; }
+      if (numPending === 0) {
+        return SAVED_MESSAGE;
+      }
       return [
         SAVING_MESSAGE,
         numPending,
@@ -544,8 +559,8 @@
         // Note: the event is fired on the body rather than this element because
         // this element may not be attached by the time this executes, in which
         // case the event would not bubble.
-        document.body.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message}, bubbles: true}));
+        document.body.dispatchEvent(new CustomEvent(
+            'show-alert', {detail: {message}, bubbles: true, composed: true}));
       }, TOAST_DEBOUNCE_INTERVAL);
     },
 
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 827bb71..dc945a2 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
@@ -96,8 +96,11 @@
             this.$.storage.getEditableContentItem(this.storageKey);
         if (storedContent && storedContent.message) {
           content = storedContent.message;
-          this.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: RESTORED_MESSAGE},
+            bubbles: true,
+            composed: true,
+          }));
         }
       }
       if (!content) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
index 5f7b8ff..349e441 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
@@ -26,6 +26,8 @@
     // notifying their listeners in the notify function.
     this._annotationLayers = [];
 
+    this._coverageProvider = null;
+
     // Default impl is a no-op.
     this._addLayerFunc = annotationActionsContext => {};
   }
@@ -56,6 +58,37 @@
   };
 
   /**
+   * The specified function will be called when a gr-diff component is built,
+   * and feeds the returned coverage data into the diff. Optional.
+   *
+   * Be sure to call this only once and only from one plugin. Multiple coverage
+   * providers are not supported. A second call will just overwrite the
+   * provider of the first call.
+   *
+   * TODO(brohlfs): Replace Array<Object> type by Array<Gerrit.CoverageRange>.
+   *
+   * @param {function(changeNum, path, basePatchNum, patchNum):
+   * !Promise<!Array<Object>>} coverageProvider
+   * @return {GrAnnotationActionsInterface}
+   */
+  GrAnnotationActionsInterface.prototype.setCoverageProvider = function(
+      coverageProvider) {
+    if (this._coverageProvider) {
+      console.warn('Overwriting an existing coverage provider.');
+    }
+    this._coverageProvider = coverageProvider;
+    return this;
+  };
+
+  /**
+   * Used by Gerrit to look up the coverage provider. Not intended to be called
+   * by plugins.
+   */
+  GrAnnotationActionsInterface.prototype.getCoverageProvider = function() {
+    return this._coverageProvider;
+  };
+
+  /**
    * Returns a checkbox HTMLElement that can be used to toggle annotations
    * on/off. The checkbox will be initially disabled. Plugins should enable it
    * when data is ready and should add a click handler to toggle CSS on/off.
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 21fd9eb..f907ad6 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
@@ -228,6 +228,36 @@
       return layers;
     },
 
+    /**
+     * Retrieves coverage data possibly provided by a plugin.
+     *
+     * Will wait for plugins to be loaded. If multiple plugins offer a coverage
+     * provider, the first one is used. If no plugin offers a coverage provider,
+     * will resolve to [].
+     *
+     * TODO(brohlfs): Replace Array<Object> type by Array<Gerrit.CoverageRange>.
+     *
+     * @param {string|number} changeNum
+     * @param {string} path
+     * @param {string|number} basePatchNum
+     * @param {string|number} patchNum
+     * @return {!Promise<!Array<Object>>}
+     */
+    getCoverageRanges(changeNum, path, basePatchNum, patchNum) {
+      return Gerrit.awaitPluginsLoaded().then(() => {
+        for (const annotationApi of
+            this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+          const provider = annotationApi.getCoverageProvider();
+          // Only one coverage provider makes sense. If there are more, then we
+          // simply ignore them.
+          if (provider) {
+            return provider(changeNum, path, basePatchNum, patchNum);
+          }
+        }
+        return [];
+      });
+    },
+
     getAdminMenuLinks() {
       const links = [];
       for (const adminApi of
diff --git a/polygerrit-ui/app/embed/gr-diff.html b/polygerrit-ui/app/embed/gr-diff.html
index 6aa9370..3e99854 100644
--- a/polygerrit-ui/app/embed/gr-diff.html
+++ b/polygerrit-ui/app/embed/gr-diff.html
@@ -20,6 +20,5 @@
   let Gerrit = window.Gerrit || {};
   window.Gerrit = Gerrit;
 </script>
-<link rel="import" href="../styles/themes/app-theme.html">
 <link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
 <link rel="import" href="../elements/diff/gr-diff-cursor/gr-diff-cursor.html">
diff --git a/polygerrit-ui/app/embed_test.sh b/polygerrit-ui/app/embed_test.sh
index d482796..bb08bb0 100755
--- a/polygerrit-ui/app/embed_test.sh
+++ b/polygerrit-ui/app/embed_test.sh
@@ -16,8 +16,7 @@
 
 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=[\'\']
+    FIREFOX_OPTIONS=[\'-headless\']
 else
     CHROME_OPTIONS=[\'start-maximized\']
     FIREFOX_OPTIONS=[\'\']
diff --git a/polygerrit-ui/app/run_template_test.sh b/polygerrit-ui/app/run_template_test.sh
index 4cd6e7f..d2b6989 100755
--- a/polygerrit-ui/app/run_template_test.sh
+++ b/polygerrit-ui/app/run_template_test.sh
@@ -3,7 +3,7 @@
 if [[ -z "${TEMPLATE_NO_DEFAULT}" ]]; then
 bazel test \
       --test_env="HOME=$HOME" \
-      //polygerrit-ui/app:all
+      //polygerrit-ui/app:all \
       --test_tag_filters=template \
       "$@" \
       --test_output errors \
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index df210b8..3d92e11 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,9 +6,29 @@
     exit 1
 fi
 
+# From https://www.linuxquestions.org/questions/programming-9/bash-script-return-full-path-and-filename-680368/page3.html
+function abs_path {
+  if [[ -d "$1" ]]
+  then
+      pushd "$1" >/dev/null
+      pwd
+      popd >/dev/null
+  elif [[ -e $1 ]]
+  then
+      pushd "$(dirname "$1")" >/dev/null
+      echo "$(pwd)/$(basename "$1")"
+      popd >/dev/null
+  else
+      echo "$1" does not exist! >&2
+      return 127
+  fi
+}
 wct_bin=$(which wct)
 if [[ -z "$wct_bin" ]]; then
-    echo "WCT must be on the path. (https://github.com/Polymer/web-component-tester)"
+  wct_bin=$(abs_path ./node_modules/web-component-tester/bin/wct);
+fi
+if [[ -z "$wct_bin" ]]; then
+    echo "wct_bin must be set or WCT locally installed (npm install wct)."
     exit 1
 fi
 
diff --git a/polygerrit-ui/app/template_test.sh b/polygerrit-ui/app/template_test.sh
index 7177e8a..b1a2380 100755
--- a/polygerrit-ui/app/template_test.sh
+++ b/polygerrit-ui/app/template_test.sh
@@ -14,19 +14,6 @@
     exit 1
 fi
 
-fried_twinkie_config=$(npm list -g | grep -c fried-twinkie)
-if [ -z "$npm_bin" ] || [ "$fried_twinkie_config" -eq "0" ]; then
-    echo "You must install fried twinkie and its dependencies from NPM."
-    echo "> npm install -g fried-twinkie"
-    exit 1
-fi
-
-twinkie_version=$(npm list -g fried-twinkie@\>0.1 | grep fried-twinkie || :)
-if [ -z "$twinkie_version" ]; then
-    echo "Outdated version of fried-twinkie found. Bypassing template check."
-    exit 0
-fi
-
 # Have to find where node_modules are installed and set the NODE_PATH
 
 get_node_path() {
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
index a8394cd..f1b4666 100755
--- a/polygerrit-ui/app/wct_test.sh
+++ b/polygerrit-ui/app/wct_test.sh
@@ -14,8 +14,7 @@
 
 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=[\'\']
+    FIREFOX_OPTIONS=[\'-headless\']
 else
     CHROME_OPTIONS=[\'start-maximized\']
     FIREFOX_OPTIONS=[\'\']
@@ -60,9 +59,9 @@
     };
 EOF
 
-export PATH="$(dirname $WCT):$(dirname $NPM):$PATH"
+export PATH="$(dirname $NPM):$PATH"
 
 cd $t
 test -n "${WCT}"
 
-$(basename ${WCT}) ${WCT_ARGS}
+${WCT} ${WCT_ARGS}
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD
new file mode 100644
index 0000000..4048bc7
--- /dev/null
+++ b/prologtests/examples/BUILD
@@ -0,0 +1,7 @@
+package(default_visibility = ["//visibility:public"])
+
+sh_test(
+    name = "test_examples",
+    srcs = ["run.sh"],
+    data = glob(["*.pl"]) + ["//:gerrit.war"],
+)
diff --git a/prologtests/examples/README.md b/prologtests/examples/README.md
new file mode 100644
index 0000000..12eb256e
--- /dev/null
+++ b/prologtests/examples/README.md
@@ -0,0 +1,54 @@
+# Prolog Unit Test Examples
+
+## Run all examples
+
+Build a local gerrit.war and then run the script:
+
+    ./run.sh
+
+Note that a local Gerrit server is not needed because
+these unit test examples redefine wrappers of the `gerrit:change\*`
+rules to provide mocked change data.
+
+## Add a new unit test
+
+Please follow the pattern in `t1.pl`, `t2.pl`, or `t3.pl`.
+
+* Put code to be tested in a file, e.g. `rules.pl`.
+  For easy unit testing, split long clauses into short ones
+  and test every positive and negative path.
+
+* Create a new unit test file, e.g. `t1.pl`,
+  which should _load_ the test source file and `utils.pl`.
+
+      % First load all source files and the utils.pl.
+      :- load([aosp_rules,utils]).
+
+      :- begin_tests(t1).  % give this test any name
+
+      % Use test0/1 or test1/1 to verify failed/passed goals.
+
+      :- end_tests(_,0).   % check total pass/fail counts
+
+* Optionally replace calls to gerrit functions that depend on repository.
+  For example, define the following wrappers and in source code, use
+  `change_branch/1` instead of `gerrti:change_branch/1`.
+
+      change_branch(X) :- gerrit:change_branch(X).
+      commit_label(L,U) :- gerrit:commit_label(L,U).
+
+* In unit test file, redefine the gerrit function wrappers and test.
+  For example, in `t3.pl`, we have:
+
+      :- redefine(uploader,1,uploader(user(42))).  % mocked uploader
+      :- test1(uploader(user(42))).
+      :- test0(is_exempt_uploader).
+
+      % is_exempt_uploader/0 is expected to fail because it is
+      % is_exempt_uploader :- uploader(user(Id)), memberchk(Id, [104, 106]).
+
+      % Note that gerrit:remove_label does not depend on Gerrit repository,
+      % so its caller remove_label/1 is tested without any redefinition.
+
+      :- test1(remove_label('MyReview',[],[])).
+      :- test1(remove_label('MyReview',submit(),submit())).
diff --git a/prologtests/examples/aosp_rules.pl b/prologtests/examples/aosp_rules.pl
new file mode 100644
index 0000000..18e8a73
--- /dev/null
+++ b/prologtests/examples/aosp_rules.pl
@@ -0,0 +1,148 @@
+% A simplified and mocked AOSP rules.pl
+
+%%%%% wrapper functions for unit tests
+
+change_branch(X) :- gerrit:change_branch(X).
+change_project(X) :- gerrit:change_project(X).
+commit_author(U,N,M) :- gerrit:commit_author(U,N,M).
+commit_delta(X) :- gerrit:commit_delta(X).
+commit_label(L,U) :- gerrit:commit_label(L,U).
+uploader(X) :- gerrit:uploader(X).
+
+%%%%% true/false conditions
+
+% Special auto-merger accounts.
+is_exempt_uploader :-
+  uploader(user(Id)),
+  memberchk(Id, [104, 106]).
+
+% Build cop overrides everything.
+has_build_cop_override :-
+  commit_label(label('Build-Cop-Override', 1), _).
+
+is_exempt_from_reviews :-
+  or(is_exempt_uploader, has_build_cop_override).
+
+% Some files in selected projects need API review.
+needs_api_review :-
+  commit_delta('^(.*/)?api/|^(system-api/)'),
+  change_project(Project),
+  memberchk(Project, [
+    'platform/external/apache-http',
+    'platform/frameworks/base',
+    'platform/frameworks/support',
+    'platform/packages/services/Car',
+    'platform/prebuilts/sdk'
+  ]).
+
+% Some branches need DrNo review.
+needs_drno_review :-
+  change_branch(Branch),
+  memberchk(Branch, [
+    'refs/heads/my-alpha-dev',
+    'refs/heads/my-beta-dev'
+  ]).
+
+% Some author email addresses need Qualcomm-Review.
+needs_qualcomm_review :-
+  commit_author(_, _, M),
+  regex_matches(
+'.*@(qti.qualcomm.com|qca.qualcomm.com|quicinc.com|qualcomm.com)', M).
+
+% Special projects, branches, user accounts
+% can opt out owners review.
+opt_out_find_owners :-
+  change_branch(Branch),
+  memberchk(Branch, [
+    'refs/heads/my-beta-testing',
+    'refs/heads/my-testing'
+  ]).
+
+% Special projects, branches, user accounts
+% can opt in owners review.
+% Note that opt_out overrides opt_in.
+opt_in_find_owners :- true.
+
+
+%%%%% Simple list filters.
+
+remove_label(X, In, Out) :-
+  gerrit:remove_label(In, label(X, _), Out).
+
+% Slow but simple for short input list.
+remove_review_categories(In, Out) :-
+  remove_label('API-Review', In, L1),
+  remove_label('Code-Review', L1, L2),
+  remove_label('DrNo-Review', L2, L3),
+  remove_label('Owner-Review-Vote', L3, L4),
+  remove_label('Qualcomm-Review', L4, L5),
+  remove_label('Verified', L5, Out).
+
+
+%%%%% Missing rules in Gerrit Prolog Cafe.
+
+or(InA, InB) :- once((A;B)).
+
+not(Goal) :- Goal -> false ; true.
+
+% memberchk(+Element, +List)
+memberchk(X, [H|T]) :-
+  (X = H -> true ; memberchk(X, T)).
+
+maplist(Functor, In, Out) :-
+  (In = []
+  -> Out = []
+  ;  (In = [X1|T1],
+      Out = [X2|T2],
+      Goal =.. [Functor, X1, X2],
+      once(Goal),
+      maplist(Functor, T1, T2)
+     )
+  ).
+
+
+%%%%% Conditional rules and filters.
+
+submit_filter(In, Out) :-
+  (is_exempt_from_reviews
+  -> remove_review_categories(In, Out)
+  ;  (check_review(needs_api_review,
+          'API_Review', In, L1),
+      check_review(needs_drno_review,
+          'DrNo-Review', L1, L2),
+      check_review(needs_qualcomm_review,
+          'Qualcomm-Review', L2, L3),
+      check_find_owners(L3, Out)
+     )
+  ).
+
+check_review(NeedReview, Label, In, Out) :-
+  (NeedReview
+  -> Out = In
+  ;  remove_label(Label, In, Out)
+  ).
+
+% If opt_out_find_owners is true,
+% remove all 'Owner-Review-Vote' label;
+% else if opt_in_find_owners is true,
+%      call find_owners:submit_filter;
+% else default to no find_owners filter.
+check_find_owners(In, Out) :-
+  (opt_out_find_owners
+  -> remove_label('Owner-Review-Vote', In, Temp)
+  ; (opt_in_find_owners
+    -> find_owners:submit_filter(In, Temp)
+    ; In = Temp
+    )
+  ),
+  Temp =.. [submit | L1],
+  remove_label('Owner-Approved', L1, L2),
+  maplist(owner_may_to_need, L2, L3),
+  Out =.. [submit | L3].
+
+% change may(_) to need(_) to block submit.
+owner_may_to_need(In, Out) :-
+  (In = label('Owner-Review-Vote', may(_))
+  -> Out = label('Owner-Review-Vote', need(_))
+  ;  Out = In
+  ).
diff --git a/prologtests/examples/load.pl b/prologtests/examples/load.pl
new file mode 100644
index 0000000..f5b49e8
--- /dev/null
+++ b/prologtests/examples/load.pl
@@ -0,0 +1,26 @@
+% If you have 1.4.3 or older Prolog-Cafe, you need to
+% use (consult(load), load(load)) to get definition of load.
+% Then use load([f1,f2,...]) to load multiple source files.
+
+% Input is a list of file names or a single file name.
+% Use a conditional expression style without cut operator.
+load(X) :-
+  ( (X = [])
+  -> true
+  ; ( (X = [H|T])
+    -> (load_file(H), load(T))
+    ;  load_file(X)
+    )
+  ).
+
+% load_file is '$consult' without the bug of unbound 'File' variable.
+% For repeated unit tests, skip statistics and print_message.
+load_file(F) :- atom(F), !,
+  '$prolog_file_name'(F, PF),
+  open(PF, read, In),
+  % print_message(info, [loading,PF,'...']),
+  % statistics(runtime, _),
+  consult_stream(PF, In),
+  % statistics(runtime, [_,T]),
+  % print_message(info, [PF,'loaded in',T,msec]),
+  close(In).
diff --git a/prologtests/examples/rules.pl b/prologtests/examples/rules.pl
new file mode 100644
index 0000000..1a7b17c
--- /dev/null
+++ b/prologtests/examples/rules.pl
@@ -0,0 +1,29 @@
+% An example source file to be tested.
+
+% Add common rules missing in Prolog Cafe.
+memberchk(X, [H|T]) :-
+  (X = H) -> true ; memberchk(X, T).
+
+% A rule that can succeed/backtrack multiple times.
+super_users(1001).
+super_users(1002).
+
+% Deterministic rule that pass/fail only once.
+is_super_user(X) :- memberchk(X, [1001, 1002]).
+
+% Another rule that can pass 5 times.
+multi_users(101).
+multi_users(102).
+multi_users(103).
+multi_users(104).
+multi_users(105).
+
+% Okay, single deterministic fact.
+single_user(abc).
+
+% Wrap calls to gerrit repository, to be redefined in tests.
+change_owner(X) :- gerrit:change_owner(X).
+
+% To test is_owner without gerrit:change_owner,
+% we should redefine change_owner.
+is_owner(X) :- change_owner(X).
diff --git a/prologtests/examples/run.sh b/prologtests/examples/run.sh
new file mode 100755
index 0000000..947c153
--- /dev/null
+++ b/prologtests/examples/run.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+TESTS="t1 t2 t3"
+
+# Note that both t1.pl and t2.pl test code in rules.pl.
+# Unit tests are usually longer than the tested code.
+# So it is common to test one source file with multiple
+# unit test files.
+
+LF=$'\n'
+PASS=""
+FAIL=""
+
+echo "#### TEST_SRCDIR = ${TEST_SRCDIR}"
+
+if [ "${TEST_SRCDIR}" == "" ]; then
+  # Assume running alone
+  GERRIT_WAR="../../bazel-bin/gerrit.war"
+  SRCDIR="."
+else
+  # Assume running from bazel
+  GERRIT_WAR=`pwd`/gerrit.war
+  SRCDIR="prologtests/examples"
+fi
+
+# Default GERRIT_TMP is ~/.gerritcodereview/tmp,
+# which won't be writable in a bazel test sandbox.
+/bin/mkdir -p /tmp/gerrit
+export GERRIT_TMP=/tmp/gerrit
+
+for T in $TESTS
+do
+
+  pushd $SRCDIR
+
+  # Unit tests do not need to define clauses in packages.
+  # Use one prolog-shell per unit test, to avoid name collision.
+  echo "### Running test ${T}.pl"
+  echo "[$T]." | java -jar ${GERRIT_WAR} prolog-shell -q -s load.pl
+
+  if [ "x$?" != "x0" ]; then
+    echo "### Test ${T}.pl failed."
+    FAIL="${FAIL}${LF}FAIL: Test ${T}.pl"
+  else
+    PASS="${PASS}${LF}PASS: Test ${T}.pl"
+  fi
+
+  popd
+
+  # java -jar ../../bazel-bin/gerrit.war prolog-shell -s $T < /dev/null
+  # Calling prolog-shell with -s flag works for small files,
+  # but got run-time exception with t3.pl.
+  #   com.googlecode.prolog_cafe.exceptions.ReductionLimitException:
+  #   exceeded reduction limit of 1048576
+done
+
+echo "$PASS"
+
+if [ "$FAIL" != "" ]; then
+  echo "$FAIL"
+  exit 1
+fi
diff --git a/prologtests/examples/t1.pl b/prologtests/examples/t1.pl
new file mode 100644
index 0000000..caf9061
--- /dev/null
+++ b/prologtests/examples/t1.pl
@@ -0,0 +1,20 @@
+:- load([rules,utils]).
+:- begin_tests(t1).
+
+:- test1(true).     % expect true to pass
+:- test0(false).    % expect false to fail
+
+:- test1(X = 3).    % unification should pass
+:- test1(_ = 3).    % unification should pass
+:- test0(X \= 3).   % not-unified should fail
+
+% (7-4) should have expected result
+:- test1((X is (7-4), X =:= 3)).
+:- test1((X is (7-4), X =\= 4)).
+
+% memberchk should pass/fail exactly once
+:- test1(memberchk(3,[1,3,5,3])).
+:- test0(memberchk(2,[1,3,5,3])).
+:- test0(memberchk(2,[])).
+
+:- end_tests_or_halt(0).  % expect no failure
diff --git a/prologtests/examples/t2.pl b/prologtests/examples/t2.pl
new file mode 100644
index 0000000..9424b53
--- /dev/null
+++ b/prologtests/examples/t2.pl
@@ -0,0 +1,25 @@
+:- load([rules,utils]).
+:- begin_tests(t2).
+
+% expected to pass or fail once.
+:- test0(super_users(1000)).
+:- test1(super_users(1001)).
+
+:- test1(is_super_user(1001)).
+:- test1(is_super_user(1002)).
+:- test0(is_super_user(1003)).
+
+:- test1(super_users(X)).  % expected fail (pass twice)
+:- test1(multi_users(X)).  % expected fail (pass many times)
+
+:- test1(single_user(X)).  % expected pass once
+
+% Redefine change_owner, skip gerrit:change_owner,
+% then test is_owner without a gerrit repository.
+
+:- redefine(change_owner,1,(change_owner(42))).
+:- test1(is_owner(42)).
+:- test1(is_owner(X)).
+:- test0(is_owner(24)).
+
+:- end_tests_or_halt(2).  % expect 2 failures
diff --git a/prologtests/examples/t3.pl b/prologtests/examples/t3.pl
new file mode 100644
index 0000000..02badc0
--- /dev/null
+++ b/prologtests/examples/t3.pl
@@ -0,0 +1,69 @@
+:- load([aosp_rules,utils]).
+
+:- begin_tests(t3_basic_conditions).
+
+%% A negative test of is_exempt_uploader.
+:- redefine(uploader,1,uploader(user(42))).  % mocked uploader
+:- test1(uploader(user(42))).
+:- test0(is_exempt_uploader).
+
+%% Helper functions for positive test of is_exempt_uploader.
+test_is_exempt_uploader(List) :- maplist(test1_uploader, List, _).
+test1_uploader(X,_) :-
+  redefine(uploader,1,uploader(user(X))),
+  test1(uploader(user(X))),
+  test1(is_exempt_uploader).
+:- test_is_exempt_uploader([104, 106]).
+
+%% Test has_build_cop_override.
+:- redefine(commit_label,2,commit_label(label('Code-Review',1),user(102))).
+:- test0(has_build_cop_override).
+commit_label(label('Build-Cop-Override',1),user(101)).  % mocked 2nd label
+:- test1(has_build_cop_override).
+:- test1(commit_label(label(_,_),_)).           % expect fail, two matches
+:- test1(commit_label(label('Build-Cop-Override',_),_)).  % good, one pass
+
+%% TODO: more test for is_exempt_from_reviews.
+
+%% Test needs_api_review, which checks commit_delta and project.
+% Helper functions:
+test_needs_api_review(File, Project, Tester) :-
+  redefine(commit_delta,1,(commit_delta(R) :- regex_matches(R, File))),
+  redefine(change_project,1,change_project(Project)),
+  Goal =.. [Tester, needs_api_review],
+  msg('# check CL with changed file ', File, ' in ', Project),
+  once((Goal ; true)).  % do not backtrack
+
+:- test_needs_api_review('apio/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('d1/d2/api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('system-api/d/t.c', 'platform/external/apache-http', test1).
+
+%% TODO: Test needs_drno_review, needs_qualcomm_review
+
+%% TODO: Test opt_out_find_owners.
+
+:- test1(opt_in_find_owners).  % default, unless opt_out_find_owners
+
+:- end_tests_or_halt(1).  % expect 1 failure of multiple commit_label
+
+%% Test remove_label
+:- begin_tests(t3_remove_label).
+
+:- test1(remove_label('MyReview',[],[])).
+:- test1(remove_label('MyReview',submit(),submit())).
+:- test1(remove_label(myR,[label(a,X)],[label(a,X)])).
+:- test1(remove_label(myR,[label(myR,_)],[])).
+:- test1(remove_label(myR,[label(a,X),label(myR,_)],[label(a,X)])).
+:- test1(remove_label(myR,submit(label(a,X)),submit(label(a,X)))).
+:- test1(remove_label(myR,submit(label(myR,_)),submit())).
+
+%% Test maplist
+double(X,Y) :- Y is X * X.
+:- test1(maplist(double, [2,4,6], [4,16,36])).
+:- test1(maplist(double, [], [])).
+
+:- end_tests_or_halt(0).  % expect no failure
+
+%% TODO: Add more tests.
diff --git a/prologtests/examples/utils.pl b/prologtests/examples/utils.pl
new file mode 100644
index 0000000..8d15067
--- /dev/null
+++ b/prologtests/examples/utils.pl
@@ -0,0 +1,78 @@
+%% Unit test helpers
+
+% Write one line message.
+msg(A) :- write(A), nl.
+msg(A,B) :- write(A), msg(B).
+msg(A,B,C) :- write(A), msg(B,C).
+msg(A,B,C,D) :- write(A), msg(B,C,D).
+msg(A,B,C,D,E) :- write(A), msg(B,C,D,E).
+msg(A,B,C,D,E,F) :- write(A), msg(B,C,D,E,F).
+
+% Redefine a caluse.
+redefine(Atom,Arity,Clause) :- abolish(Atom/Arity), assertz(Clause).
+
+% Increment/decrement of pass/fail counters.
+set_counters(N,X,Y) :- redefine(test_count,3,test_count(N,X,Y)).
+get_counters(N,X,Y) :- clause(test_count(N,X,Y), _) -> true ; (X=0, Y=0).
+inc_pass_count :- get_counters(N,P,F), P1 is P + 1, set_counters(N,P1,F).
+inc_fail_count :- get_counters(N,P,F), F1 is F + 1, set_counters(N,P,F1).
+
+% Report pass or fail of G.
+pass_1(G) :- msg('PASS: ', G), inc_pass_count.
+fail_1(G) :- msg('FAIL: ', G), inc_fail_count.
+
+% Report pass or fail of not(G).
+pass_0(G) :- msg('PASS: not(', G, ')'), inc_pass_count.
+fail_0(G) :- msg('FAIL: not(', G, ')'), inc_fail_count.
+
+% Report a test as failed if it passed 2 or more times
+pass_twice(G) :-
+  msg('FAIL: (pass twice): ', G),
+  inc_fail_count.
+pass_many(G) :-
+  G = [A,B|_],
+  length(G, N),
+  msg('FAIL: (pass ', N, ' times): ', [A,B,'...']),
+  inc_fail_count.
+
+% Test if G fails.
+test0(G) :- once(G) -> fail_0(G) ; pass_0(G).
+
+% Test if G passes exactly once.
+test1(G) :-
+  findall(G, G, S), length(S, N),
+  (N == 0
+   -> fail_1(G)
+   ;  (N == 1
+       -> pass_1(S)
+       ;  (N == 2 -> pass_twice(S) ; pass_many(S))
+      )
+  ).
+
+% Report the begin of test N.
+begin_tests(N) :-
+  nl,
+  msg('BEGIN test ',N),
+  set_counters(N,0,0).
+
+% Repot the end of test N and total pass/fail counts,
+% and check if the numbers are as exected OutP/OutF.
+end_tests(OutP,OutF) :-
+  get_counters(N,P,F),
+  (OutP = P
+   -> msg('Expected #PASS: ', OutP)
+   ;  (msg('ERROR: expected #PASS is ',OutP), !, fail)
+  ),
+  (OutF = F
+   -> msg('Expected #FAIL: ', OutF)
+   ;  (msg('ERROR: expected #FAIL is ',OutF), !, fail)
+  ),
+  msg('END test ', N),
+  nl.
+
+% Repot the end of test N and total pass/fail counts.
+end_tests(N) :- end_tests(N,_,_).
+
+% Call end_tests/2 and halt if the fail count is unexpected.
+end_tests_or_halt(ExpectedFails) :-
+  end_tests(_,ExpectedFails); (flush_output, halt(1)).
diff --git a/proto/cache.proto b/proto/cache.proto
index b34dbf3..77b6908 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -75,7 +75,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 19
+// Next ID: 20
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -183,6 +183,9 @@
 
   reserved 17;  // read_only_until
   reserved 18;  // has_read_only_until
+
+  // Number of updates to the change's meta ref.
+  int32 update_count = 19;
 }
 
 
diff --git a/proto/entities.proto b/proto/entities.proto
index d2851d3..153fe4e 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -84,14 +84,14 @@
 // Next ID: 3
 message PatchSet_Id {
   required Change_Id change_id = 1;
-  required int32 patch_set_id = 2;
+  required int32 id = 2;
 }
 
 // Serialized form of com.google.gerrit.reviewdb.client.PatchSet.
 // Next ID: 10
 message PatchSet {
   required PatchSet_Id id = 1;
-  optional RevId revision = 2;
+  optional ObjectId commitId = 2;
   optional Account_Id uploader_account_id = 3;
   optional fixed64 created_on = 4;
   optional string groups = 6;
@@ -120,7 +120,7 @@
 message PatchSetApproval_Key {
   required PatchSet_Id patch_set_id = 1;
   required Account_Id account_id = 2;
-  required LabelId category_id = 3;
+  required LabelId label_id = 3;
 }
 
 // Serialized form of com.google.gerrit.reviewdb.client.PatchSetApproval.
@@ -147,12 +147,13 @@
 // Serialized form of com.google.gerrit.reviewdb.client.Branch.NameKey.
 // Next ID: 3
 message Branch_NameKey {
-  optional Project_NameKey project_name = 1;
-  optional string branch_name = 2;
+  optional Project_NameKey project = 1;
+  optional string branch = 2;
 }
 
-// Serialized form of com.google.gerrit.reviewdb.client.RevId.
+// Serialized form of org.eclipse.jgit.lib.ObjectId.
 // Next ID: 2
-message RevId {
-  optional string id = 1;
+message ObjectId {
+  // Hex string representation of the ID.
+  optional string name = 1;
 }
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 821e037..7bc07b1 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -8,6 +8,10 @@
 
 ECLIPSE = "ECLIPSE:"
 
+MAVEN_SNAPSHOT = "https://oss.sonatype.org/content/repositories/snapshots"
+
+SNAPSHOT = "-SNAPSHOT-"
+
 def _maven_release(ctx, parts):
     """induce jar and url name from maven coordinates."""
     if len(parts) not in [3, 4]:
@@ -20,9 +24,25 @@
         group, artifact, version = parts
         file_version = version
 
+    repository = ctx.attr.repository
+
+    if "-SNAPSHOT-" in version:
+        start = version.index(SNAPSHOT)
+        end = start + len(SNAPSHOT) - 1
+
+        # file version without snapshot constant, but with post snapshot suffix
+        file_version = version[:start] + version[end:]
+
+        # version without post snapshot suffix
+        version = version[:end]
+
+        # overwrite the repository with Maven snapshot repository
+        repository = MAVEN_SNAPSHOT
+
     jar = artifact.lower() + "-" + file_version
+
     url = "/".join([
-        ctx.attr.repository,
+        repository,
         group.replace(".", "/"),
         artifact,
         version,
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 687e6eb..14a07d1 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/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>3.0.1-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index d0c0131..0cf1448 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/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>3.0.1-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 3320616..ebb66b4 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/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>3.0.1-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index f196fa6..51e517b 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.0.1-SNAPSHOT</version>
+  <version>3.1.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/version.bzl b/version.bzl
index f89e723..42a576c 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.0.1-SNAPSHOT"
+GERRIT_VERSION = "3.1.0-SNAPSHOT"