Merge "Set maxObjectSizeLimit when project is created via REST"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 5102033..9ff0156 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -419,8 +419,9 @@
 to projects in Gerrit. It can give permission to abandon a specific
 change to a given ref.
 
-This also grants the permission to restore a change if the change
-can be uploaded.
+This also grants the permission to restore a change if the user also
+has link:#category_push[push permission] on the change's destination
+ref.
 
 
 [[category_create]]
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 8de8e59..53c9750 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -65,6 +65,13 @@
 related to a user editing the commit message through the Gerrit UI.  It is a
 `ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
 
+Footer.vm
+~~~~~~~~~
+
+The `Footer.vm` template will determine the contents of the footer text
+appended to the end of all outgoing emails after the ChangeFooter and
+CommentFooter.
+
 Merged.vm
 ~~~~~~~~~
 
diff --git a/Documentation/error-missing-changeid.txt b/Documentation/error-missing-changeid.txt
index b13f3b4..edbc63b 100644
--- a/Documentation/error-missing-changeid.txt
+++ b/Documentation/error-missing-changeid.txt
@@ -6,11 +6,12 @@
 message if the commit message of the pushed commit does not contain
 a Change-Id in the footer (the last paragraph).
 
-This error may happen for two reasons:
+This error may happen for different reasons:
 
 . missing Change-Id in the commit message
 . Change-Id is contained in the commit message but not in the last
   paragraph
+. Change-Id is the only line in the commit message
 
 You can see the commit messages for existing commits in the history
 by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
@@ -51,6 +52,20 @@
 Change-ID into the last paragraph. How to update the commit message
 is explained link:error-push-fails-due-to-commit-message.html[here].
 
+Change-Id is the only line in the commit message
+------------------------------------------------
+
+Gerrit does not parse the subject of a commit message for the
+Change-Id even if this is the only and last paragraph of the commit
+message.
+
+If the Change-Id is the only line in the commit message you must update
+the commit message and insert a subject as the first line in the commit
+message. The Change-Id must be in the last paragraph of the commit
+message, i.e. separated from the subject by a blank line. How to update
+the commit message is explained
+link:error-push-fails-due-to-commit-message.html[here].
+
 
 GERRIT
 ------
diff --git a/Documentation/json.txt b/Documentation/json.txt
index 700b145..2836e5b 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -115,6 +115,8 @@
 createdOn:: Time in seconds since the UNIX epoch when this patchset
 was created.
 
+isDraft:: Whether or not the patch set is a draft patch set.
+
 approvals:: The <<approval,approval attribute>> granted.
 
 comments:: All comments for this patchset in <<patchsetcomment,patchsetComment attributes>>.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 1d8b934..c0cf578 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -364,6 +364,11 @@
 detailed labels], link:#detailed-accounts[detailed accounts], and
 link:#messages[messages].
 
+Additional fields can be obtained by adding `o` parameters, each
+option requires more database lookups and slows down the query
+response time to the client so they are generally disabled by
+default. Fields are described in link:#list-changes[Query Changes].
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/detail HTTP/1.0
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 70f564c..6af6897 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -9,6 +9,28 @@
 Config Endpoints
 ---------------
 
+[[get-version]]
+Get Version
+~~~~~~~~~~~
+[verse]
+'GET /config/server/version'
+
+Returns the version of the Gerrit server.
+
+.Request
+----
+  GET /config/server/version HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  "2.7"
+----
+
 [[list-capabilities]]
 List Capabilities
 ~~~~~~~~~~~~~~~~~
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 711d1ac..558dcb5 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -22,6 +22,14 @@
 change notifications to specific subsets, for example `branch:master`
 to only see changes proposed for the master branch.
 
+Notification mails for new changes and new patch sets are not sent to
+the change owner.
+
+Notification mails for comments added on changes are not sent to the user
+who added the comment unless the user has enabled the 'CC Me On Comments I
+Write' option in the user preferences.
+
+
 Project Level Settings
 ----------------------
 
diff --git a/ReleaseNotes/ReleaseNotes-2.8.txt b/ReleaseNotes/ReleaseNotes-2.8.txt
index c321814..b7ebc0b 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.txt
@@ -29,6 +29,18 @@
 ------------
 
 
+Configuration
+~~~~~~~~~~~~~
+
+* Project owners can define `receive.maxObjectSizeLimit` in the
+link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.8/config-gerrit.html#receive.maxObjectSizeLimit[
+project configuration] to further reduce the global setting.
+
+* Site administrators can define a
+link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.8/config-mail.html#_footer_vm[
+footer template] that will be appended to the end of all outgoing emails after
+the 'ChangeFooter' and 'CommentFooter'.
+
 Web UI
 ~~~~~~
 
@@ -154,6 +166,9 @@
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.8/rest-api-config.html#get-capabilities[
 Get capabilities]
 
+* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.8/rest-api-config.html#get-version[
+Get version] (of the Gerrit server)
+
 
 Projects
 ^^^^^^^^
@@ -185,7 +200,7 @@
 New global capabilities are added.
 
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.8/access_control.html#capability_generateHttpPassword[
-Generate Http Password] Allows non-administrator users to generate HTTP
+Generate Http Password] allows non-administrator users to generate HTTP
 passwords for users other than themselves.
 +
 This capability would typically be assigned to a non-interactive group
@@ -193,7 +208,7 @@
 that uses the Gerrit REST API.
 
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.8/access_control.html#capability_runAs[
-Run As] Allows users to impersonate other users by setting the `X-Gerrit-RunAs`
+Run As] allows users to impersonate other users by setting the `X-Gerrit-RunAs`
 HTTP header on REST API calls.
 +
 Site administrators do not inherit this capability;  it must be granted
@@ -204,11 +219,25 @@
 ~~~~~~~
 
 
-* The commit message length checker plugin can be configured to reject
-commits whose subject or body length exceeds the limit.
+Global
+^^^^^^
+
 
 * Plugins may now contribute buttons to various parts of the UI.
 
+Commit Message Length Checker
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+* Commits whose subject or body length exceeds the limit can be rejected.
+
+Replication
+^^^^^^^^^^^
+
+* The `{$name}` placeholder is optional when replicating a single project,
+allowing a single project to be replicated under a different name.
+
+* Projects can be matched with wildcard or regex patterns in replication.config.
 
 ssh
 ~~~
@@ -222,7 +251,7 @@
 New `ls-members` command].
 
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.8/cmd-set-members.html[
-New `set-members` command]
+New `set-members` command].
 +
 New command to manipulate group membership. Members can be added or removed
 and groups can be included or excluded in one specific group or number of groups.
@@ -253,6 +282,9 @@
 * link:https://code.google.com/p/gerrit/issues/detail?id=1574[Issue 1574]:
 Correctly highlight matches of text in escaped HTML entities in suggestion results.
 
+* link:https://code.google.com/p/gerrit/issues/detail?id=1996[Issue 1996]:
+The "Keyboard Shortcuts" help popup can be closed by pressing the Escape key.
+
 
 Change Screens
 ^^^^^^^^^^^^^^
@@ -302,6 +334,14 @@
 * link:https://code.google.com/p/gerrit/issues/detail?id=1908[Issue 1908]:
 Provide more informative error messages when rejecting updates.
 
+* Remove the limit in the query of patch sets by revision.
+
+* Add `isDraft` in the `patchSet` attribute of stream-events data.
++
+This allows consumers of the event stream to determine whether or not
+the event is related to a draft patch set.
+
+
 Tools
 ~~~~~
 
diff --git a/gerrit-common/BUCK b/gerrit-common/BUCK
index 07bbcae..9a8c1d8 100644
--- a/gerrit-common/BUCK
+++ b/gerrit-common/BUCK
@@ -42,7 +42,7 @@
 # TODO(sop): Move git describe into an uncacheable genrule()
 def git_describe():
   import subprocess
-  cmd = ['git', 'describe', 'HEAD']
+  cmd = ['git', 'describe', '--match', 'v[0-9].*', '--dirty']
   p = subprocess.Popen(cmd, stdout = subprocess.PIPE)
   v = p.communicate()[0].strip()
   r = p.returncode
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java
new file mode 100644
index 0000000..a8ffe94
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+import java.util.concurrent.TimeUnit;
+
+public class CacheControl {
+
+  public enum Type {
+    NONE, PUBLIC, PRIVATE;
+  }
+
+  public final static CacheControl NONE = new CacheControl(Type.NONE, 0, null);
+
+  public static CacheControl PUBLIC(long age, TimeUnit unit) {
+    return new CacheControl(Type.PUBLIC, age, unit);
+  }
+
+  public static CacheControl PRIVATE(long age, TimeUnit unit) {
+    return new CacheControl(Type.PRIVATE, age, unit);
+  }
+
+  private final Type type;
+  private final long age;
+  private final TimeUnit unit;
+
+  private CacheControl(Type type, long age, TimeUnit unit) {
+    this.type = type;
+    this.age = age;
+    this.unit = unit;
+  }
+
+  public Type getType() {
+    return type;
+  }
+
+  public long getAge() {
+    return age;
+  }
+
+  public TimeUnit getUnit() {
+    return unit;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
index aa891c9..0e358ec 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
@@ -31,4 +31,10 @@
   public ResourceNotFoundException(IdString id) {
     super(id.get());
   }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public ResourceNotFoundException caching(CacheControl c) {
+    return super.caching(c);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
index 80ca08a..848004d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
@@ -19,10 +19,6 @@
   @SuppressWarnings({"rawtypes"})
   private static final Response NONE = new None();
 
-  public enum CacheControl {
-    NONE, PUBLIC, PRIVATE;
-  }
-
   /** HTTP 200 OK: pointless wrapper for type safety. */
   public static <T> Response<T> ok(T value) {
     return new Impl<T>(200, value);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java
index a6d27cd..3fae128 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java
@@ -17,6 +17,7 @@
 /** Root exception type for JSON API failures. */
 public abstract class RestApiException extends Exception {
   private static final long serialVersionUID = 1L;
+  private CacheControl caching = CacheControl.NONE;
 
   public RestApiException() {
   }
@@ -28,4 +29,14 @@
   public RestApiException(String msg, Throwable cause) {
     super(msg, cause);
   }
+
+  public CacheControl caching() {
+    return caching;
+  }
+
+  @SuppressWarnings("unchecked")
+  public <T extends RestApiException> T caching(CacheControl c) {
+    caching = c;
+    return (T) this;
+  }
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
index 7bd0233..6f8bf80 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
@@ -16,8 +16,11 @@
 
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.dom.client.KeyCodes;
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwt.event.dom.client.KeyPressHandler;
+import com.google.gwt.event.dom.client.KeyUpEvent;
+import com.google.gwt.event.dom.client.KeyUpHandler;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlowPanel;
@@ -38,7 +41,7 @@
 
 
 public class KeyHelpPopup extends PluginSafePopupPanel implements
-    KeyPressHandler {
+    KeyPressHandler, KeyUpHandler {
   private final FocusPanel focus;
 
   public KeyHelpPopup() {
@@ -77,6 +80,7 @@
     DOM.setStyleAttribute(focus.getElement(), "outline", "0px");
     DOM.setElementAttribute(focus.getElement(), "hideFocus", "true");
     focus.addKeyPressHandler(this);
+    focus.addKeyUpHandler(this);
     add(focus);
   }
 
@@ -100,6 +104,13 @@
     hide();
   }
 
+  @Override
+  public void onKeyUp(final KeyUpEvent event) {
+    if (event.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
+      hide();
+    }
+  }
+
   private void populate(final Grid lists) {
     int end[] = new int[5];
     int column = 0;
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
index 0a9f7a2..41e4abc 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
@@ -29,7 +29,9 @@
 import java.util.List;
 
 /** Immutable string safely placed as HTML without further escaping. */
-public abstract class SafeHtml {
+@SuppressWarnings("serial")
+public abstract class SafeHtml
+    implements com.google.gwt.safehtml.shared.SafeHtml {
   public static final SafeHtmlResources RESOURCES;
 
   static {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
index 9fe3267..8ff99ee 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
@@ -19,6 +19,7 @@
 /**
  * Safely constructs a {@link SafeHtml}, escaping user provided content.
  */
+@SuppressWarnings("serial")
 public class SafeHtmlBuilder extends SafeHtml {
   private static final Impl impl;
 
@@ -317,6 +318,16 @@
     return closeElement("td");
   }
 
+  /** Append "&lt;th&gt;"; attributes may be set if needed */
+  public SafeHtmlBuilder openTh() {
+    return openElement("th");
+  }
+
+  /** Append "&lt;/th&gt;" */
+  public SafeHtmlBuilder closeTh() {
+    return closeElement("th");
+  }
+
   /** Append "&lt;div&gt;"; attributes may be set if needed */
   public SafeHtmlBuilder openDiv() {
     return openElement("div");
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
index a229421..57392bf 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
@@ -14,6 +14,7 @@
 
 package com.google.gwtexpui.safehtml.client;
 
+@SuppressWarnings("serial")
 class SafeHtmlString extends SafeHtml {
   private final String html;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
index 9fa89e6..5dcccb0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
@@ -81,8 +81,7 @@
     });
 
     if (addPopup) {
-      UserPopupPanel userPopup = new UserPopupPanel(account, false, false);
-      PopupHandler popupHandler = new PopupHandler(userPopup, this);
+      PopupHandler popupHandler = new PopupHandler(account, this);
       addMouseOverHandler(popupHandler);
       addMouseOutHandler(popupHandler);
     }
@@ -115,16 +114,20 @@
   }
 
   private class PopupHandler implements MouseOverHandler, MouseOutHandler {
-    private final UserPopupPanel popup;
+    private final AccountInfo account;
     private final UIObject target;
 
+    private UserPopupPanel popup;
     private Timer showTimer;
     private Timer hideTimer;
 
-    public PopupHandler(UserPopupPanel popup, UIObject target) {
-      this.popup = popup;
+    public PopupHandler(AccountInfo account, UIObject target) {
+      this.account = account;
       this.target = target;
+    }
 
+    private UserPopupPanel createPopupPanel(AccountInfo account) {
+      UserPopupPanel popup = new UserPopupPanel(account, false, false);
       popup.addDomHandler(new MouseOverHandler() {
         @Override
         public void onMouseOver(MouseOverEvent event) {
@@ -137,6 +140,7 @@
           scheduleHide();
         }
       }, MouseOutEvent.getType());
+      return popup;
     }
 
     @Override
@@ -154,12 +158,16 @@
         hideTimer.cancel();
         hideTimer = null;
       }
-      if ((popup.isShowing() && popup.isVisible()) || showTimer != null) {
+      if ((popup != null && popup.isShowing() && popup.isVisible())
+          || showTimer != null) {
         return;
       }
       showTimer = new Timer() {
         @Override
         public void run() {
+          if (popup == null) {
+            popup = createPopupPanel(account);
+          }
           if (!popup.isShowing() || !popup.isVisible()) {
             popup.showRelativeTo(target);
           }
@@ -174,7 +182,8 @@
         showTimer.cancel();
         showTimer = null;
       }
-      if (!popup.isShowing() || !popup.isVisible() || hideTimer != null) {
+      if (popup == null || !popup.isShowing() || !popup.isVisible()
+              || hideTimer != null) {
         return;
       }
       hideTimer = new Timer() {
@@ -186,6 +195,4 @@
       hideTimer.schedule(50);
     }
   }
-
-
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
index ea836c6..ac455c7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
@@ -120,8 +120,9 @@
 
     ownerTxtBox = new NpTextBox();
     ownerTxtBox.setVisibleLength(60);
+    final AccountGroupSuggestOracle accountGroupOracle = new AccountGroupSuggestOracle();
     ownerTxt = new SuggestBox(new RPCSuggestOracle(
-        new AccountGroupSuggestOracle()), ownerTxtBox);
+        accountGroupOracle), ownerTxtBox);
     ownerTxt.setStyleName(Gerrit.RESOURCES.css().groupOwnerTextBox());
     ownerPanel.add(ownerTxt);
 
@@ -132,7 +133,9 @@
       public void onClick(final ClickEvent event) {
         final String newOwner = ownerTxt.getText().trim();
         if (newOwner.length() > 0) {
-          GroupApi.setGroupOwner(getGroupUUID(), newOwner,
+          AccountGroup.UUID ownerUuid = accountGroupOracle.getUUID(newOwner);
+          String ownerId = ownerUuid != null ? ownerUuid.get() : newOwner;
+          GroupApi.setGroupOwner(getGroupUUID(), ownerId,
               new GerritCallback<GroupInfo>() {
                 public void onSuccess(final GroupInfo result) {
                   updateOwnerGroup(result);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
index 76a0cfb..de84081 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
@@ -29,6 +29,7 @@
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.user.client.History;
+import com.google.gwt.user.client.Window;
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.HTMLTable.Cell;
@@ -77,7 +78,12 @@
 
   @Override
   protected void onOpenRow(final int row) {
-    History.newItem(Dispatcher.toGroup(getRowItem(row).getGroupId()));
+    GroupInfo groupInfo = getRowItem(row);
+    if (isInteralGroup(groupInfo)) {
+      History.newItem(Dispatcher.toGroup(groupInfo.getGroupId()));
+    } else if (groupInfo.url() != null) {
+      Window.open(groupInfo.url(), "_self", null);
+    }
   }
 
   public void display(GroupMap groups, String toHighlight) {
@@ -108,7 +114,7 @@
 
   void populate(final int row, final GroupInfo k, final String toHighlight) {
     if (k.url() != null) {
-      if (k.url().startsWith("#" + PageLinks.ADMIN_GROUPS)) {
+      if (isInteralGroup(k)) {
         table.setWidget(row, 1, new HighlightingInlineHyperlink(k.name(),
             Dispatcher.toGroup(k.getGroupId()), toHighlight));
       } else {
@@ -133,4 +139,9 @@
 
     setRowItem(row, k);
   }
+
+  private boolean isInteralGroup(final GroupInfo groupInfo) {
+    return groupInfo != null
+        && groupInfo.url().startsWith("#" + PageLinks.ADMIN_GROUPS);
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index 9375e44..2ab1a8a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -65,6 +65,10 @@
     call(id, "detail").get(cb);
   }
 
+  public static RestApi revision(int id, String revision) {
+    return change(id).view("revisions").id(revision);
+  }
+
   public static RestApi revision(PatchSet.Id id) {
     return change(id.getParentKey().get()).view("revisions").id(id.get());
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
index 7c41ea1..ab53f5b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
@@ -34,7 +34,7 @@
     for (String q : queries) {
       call.addParameterRaw("q", KeyUtil.encode(q));
     }
-    addOptions(call, ListChangesOption.LABELS);
+    addOptions(call, EnumSet.of(ListChangesOption.LABELS));
     call.get(callback);
   }
 
@@ -45,7 +45,7 @@
     if (limit > 0) {
       call.addParameter("n", limit);
     }
-    addOptions(call, ListChangesOption.LABELS);
+    addOptions(call, EnumSet.of(ListChangesOption.LABELS));
     if (!PagedSingleListScreen.MIN_SORTKEY.equals(sortkey)) {
       call.addParameter("P", sortkey);
     }
@@ -59,16 +59,14 @@
     if (limit > 0) {
       call.addParameter("n", limit);
     }
-    addOptions(call, ListChangesOption.LABELS);
+    addOptions(call, EnumSet.of(ListChangesOption.LABELS));
     if (!PagedSingleListScreen.MAX_SORTKEY.equals(sortkey)) {
       call.addParameter("N", sortkey);
     }
     call.get(callback);
   }
 
-  private static void addOptions(
-      RestApi call, ListChangesOption option1, ListChangesOption... options) {
-    EnumSet<ListChangesOption> s = EnumSet.of(option1, options);
+  static void addOptions(RestApi call, EnumSet<ListChangesOption> s) {
     call.addParameterRaw("O", Integer.toHexString(ListChangesOption.toBits(s)));
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
index 1b437ae..d87cb5e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
@@ -28,7 +29,7 @@
   }
 
   public static void comment(PatchSet.Id id, String commentId,
-      AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+      AsyncCallback<CommentInfo> cb) {
     revision(id, "comments").id(commentId).get(cb);
   }
 
@@ -38,22 +39,22 @@
   }
 
   public static void draft(PatchSet.Id id, String draftId,
-      AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+      AsyncCallback<CommentInfo> cb) {
     revision(id, "drafts").id(draftId).get(cb);
   }
 
-  public static void createDraft(PatchSet.Id id, CommentInfo content,
-      AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+  public static void createDraft(PatchSet.Id id, CommentInput content,
+      AsyncCallback<CommentInfo> cb) {
     revision(id, "drafts").put(content, cb);
   }
 
   public static void updateDraft(PatchSet.Id id, String draftId,
-      CommentInfo content, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+      CommentInput content, AsyncCallback<CommentInfo> cb) {
     revision(id, "drafts").id(draftId).put(content, cb);
   }
 
   public static void deleteDraft(PatchSet.Id id, String draftId,
-      AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+      AsyncCallback<JavaScriptObject> cb) {
     revision(id, "drafts").id(draftId).delete(cb);
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
index 7e229f6..b1f3a2a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
@@ -22,6 +22,32 @@
 import java.sql.Timestamp;
 
 public class CommentInfo extends JavaScriptObject {
+  public static CommentInfo create(String path, Side side, int line,
+      String in_reply_to, String message) {
+    CommentInfo info = createObject().cast();
+    info.setPath(path);
+    info.setSide(side);
+    info.setLine(line);
+    info.setInReplyTo(in_reply_to);
+    info.setMessage(message);
+    return info;
+  }
+
+  private final native void setId(String id) /*-{ this.id = id; }-*/;
+  private final native void setPath(String path) /*-{ this.path = path; }-*/;
+
+  private final void setSide(Side side) {
+    setSideRaw(side.toString());
+  }
+  private final native void setSideRaw(String side) /*-{ this.side = side; }-*/;
+
+  private final native void setLine(int line) /*-{ this.line = line; }-*/;
+
+  private final native void setInReplyTo(String in_reply_to) /*-{
+    this.in_reply_to = in_reply_to;
+  }-*/;
+
+  private final native void setMessage(String message) /*-{ this.message = message; }-*/;
 
   public final native String id() /*-{ return this.id; }-*/;
   public final native String path() /*-{ return this.path; }-*/;
@@ -39,7 +65,10 @@
   public final native String message() /*-{ return this.message; }-*/;
 
   public final Timestamp updated() {
-    return JavaSqlTimestamp_JsonSerializer.parseTimestamp(updatedRaw());
+    String updatedRaw = updatedRaw();
+    return updatedRaw == null
+        ? null
+        : JavaSqlTimestamp_JsonSerializer.parseTimestamp(updatedRaw());
   }
   private final native String updatedRaw() /*-{ return this.updated; }-*/;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInput.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInput.java
new file mode 100644
index 0000000..592e087
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInput.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.changes;
+
+import com.google.gerrit.common.changes.Side;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
+
+import java.sql.Timestamp;
+
+public class CommentInput extends JavaScriptObject {
+  public static CommentInput create(CommentInfo original) {
+    CommentInput input = createObject().cast();
+    input.setId(original.id());
+    input.setPath(original.path());
+    input.setSide(original.side());
+    if (original.has_line()) {
+      input.setLine(original.line());
+    }
+    input.setInReplyTo(original.in_reply_to());
+    input.setMessage(original.message());
+    return input;
+  }
+
+  public final native void setId(String id) /*-{ this.id = id; }-*/;
+  public final native void setPath(String path) /*-{ this.path = path; }-*/;
+
+  public final void setSide(Side side) {
+    setSideRaw(side.toString());
+  }
+  private final native void setSideRaw(String side) /*-{ this.side = side; }-*/;
+
+  public final native void setLine(int line) /*-{ this.line = line; }-*/;
+
+  public final native void setInReplyTo(String in_reply_to) /*-{
+    this.in_reply_to = in_reply_to;
+  }-*/;
+
+  public final native void setMessage(String message) /*-{ this.message = message; }-*/;
+  public final native String id() /*-{ return this.id; }-*/;
+  public final native String path() /*-{ return this.path; }-*/;
+
+  public final Side side() {
+    String s = sideRaw();
+    return s != null
+        ? Side.valueOf(s)
+        : Side.REVISION;
+  }
+  private final native String sideRaw() /*-{ return this.side }-*/;
+
+  public final native int line() /*-{ return this.line; }-*/;
+  public final native String in_reply_to() /*-{ return this.in_reply_to; }-*/;
+  public final native String message() /*-{ return this.message; }-*/;
+
+  public final Timestamp updated() {
+    return JavaSqlTimestamp_JsonSerializer.parseTimestamp(updatedRaw());
+  }
+  private final native String updatedRaw() /*-{ return this.updated; }-*/;
+
+  public final native boolean has_line() /*-{ return this.hasOwnProperty('line'); }-*/;
+
+  protected CommentInput() {
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CodeMirrorDemo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CodeMirrorDemo.java
index ea26f28..90973ae 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CodeMirrorDemo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CodeMirrorDemo.java
@@ -14,22 +14,27 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.ChangeInfo;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.diff.DiffInfo.Region;
 import com.google.gerrit.client.diff.DiffInfo.Span;
+import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
+import com.google.gerrit.client.projects.ConfigInfoCache;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.Screen;
 import com.google.gerrit.common.changes.Side;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.event.logical.shared.ResizeEvent;
@@ -37,20 +42,33 @@
 import com.google.gwt.event.shared.HandlerRegistration;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.rpc.AsyncCallback;
 
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.LineClassWhere;
 import net.codemirror.lib.Configuration;
+import net.codemirror.lib.KeyMap;
 import net.codemirror.lib.LineCharacter;
+import net.codemirror.lib.LineWidget;
 import net.codemirror.lib.ModeInjector;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 public class CodeMirrorDemo extends Screen {
   private static final int HEADER_FOOTER = 60 + 15 * 2 + 38;
   private static final JsArrayString EMPTY =
       JavaScriptObject.createArray().cast();
+  /**
+   * TODO: Handle ordering of line widgets. CodeMirror seems to have a bug when
+   * multiple line widgets are added to a line: it puts the first widget above
+   * the line when the line is clicked upon.
+   */
+  private static final Configuration COMMENT_BOX_CONFIG =
+      Configuration.create().set("coverGutter", true);
+
   private final PatchSet.Id base;
   private final PatchSet.Id revision;
   private final String path;
@@ -61,8 +79,13 @@
   private HandlerRegistration resizeHandler;
   private JsArray<CommentInfo> published;
   private JsArray<CommentInfo> drafts;
-  private List<Runnable> resizeCallbacks;
+  private List<CommentBox> initialBoxes;
+  private DiffInfo diff;
   private LineMapper mapper;
+  private CommentLinkProcessor commentLinkProcessor;
+  private Map<String, PublishedBox> publishedMap;
+  private Map<Integer, CommentBox> lineBoxMapA;
+  private Map<Integer, CommentBox> lineBoxMapB;
 
   public CodeMirrorDemo(
       PatchSet.Id base,
@@ -84,29 +107,25 @@
   protected void onLoad() {
     super.onLoad();
 
-    CallbackGroup group = new CallbackGroup();
-    CodeMirror.initLibrary(group.add(new GerritCallback<Void>() {
-      @Override
-      public void onSuccess(Void result) {
-      }
-    }));
+    CallbackGroup cmGroup = new CallbackGroup();
+    CodeMirror.initLibrary(cmGroup.add(CallbackGroup.<Void>emptyCallback()));
+    final CallbackGroup group = new CallbackGroup();
+    final AsyncCallback<Void> modeInjectorCb =
+        group.add(CallbackGroup.<Void>emptyCallback());
+
     DiffApi.diff(revision, path)
       .base(base)
       .wholeFile()
       .intraline()
       .ignoreWhitespace(DiffApi.IgnoreWhitespace.NONE)
-      .get(group.addFinal(new GerritCallback<DiffInfo>() {
+      .get(cmGroup.addFinal(new GerritCallback<DiffInfo>() {
         @Override
-        public void onSuccess(final DiffInfo diff) {
+        public void onSuccess(DiffInfo diffInfo) {
+          diff = diffInfo;
           new ModeInjector()
             .add(getContentType(diff.meta_a()))
             .add(getContentType(diff.meta_b()))
-            .inject(new ScreenLoadCallback<Void>(CodeMirrorDemo.this) {
-              @Override
-              protected void preDisplay(Void result) {
-                display(diff);
-              }
-            });
+            .inject(modeInjectorCb);
         }
       }));
     CommentApi.comments(revision,
@@ -114,11 +133,33 @@
       @Override
       public void onSuccess(NativeMap<JsArray<CommentInfo>> m) { published = m.get(path); }
     }));
-    CommentApi.drafts(revision,
-        group.add(new GerritCallback<NativeMap<JsArray<CommentInfo>>>() {
+    if (Gerrit.isSignedIn()) {
+      CommentApi.drafts(revision,
+          group.add(new GerritCallback<NativeMap<JsArray<CommentInfo>>>() {
+        @Override
+        public void onSuccess(NativeMap<JsArray<CommentInfo>> m) { drafts = m.get(path); }
+      }));
+    } else {
+      drafts = JsArray.createArray().cast();
+    }
+    ChangeApi.detail(revision.getParentKey().get(), new GerritCallback<ChangeInfo>() {
       @Override
-      public void onSuccess(NativeMap<JsArray<CommentInfo>> m) { drafts = m.get(path); }
-    }));
+      public void onSuccess(ChangeInfo result) {
+        Project.NameKey project = result.project_name_key();
+        ConfigInfoCache.get(project, group.addFinal(
+            new ScreenLoadCallback<ConfigInfoCache.Entry>(CodeMirrorDemo.this) {
+          @Override
+          protected void preDisplay(ConfigInfoCache.Entry result) {
+            commentLinkProcessor = result.getCommentLinkProcessor();
+            setTheme(result.getTheme());
+
+            DiffInfo diffInfo = diff;
+            diff = null;
+            display(diffInfo);
+          }
+        }));
+      }
+    });
   }
 
   @Override
@@ -131,20 +172,15 @@
       cmB.refresh();
     }
     Window.enableScrolling(false);
-    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-      @Override
-      public void execute() {
-        for (Runnable r : resizeCallbacks) {
-          r.run();
-        }
-        resizeCallbacks = null;
-      }
-    });
+    for (CommentBox box : initialBoxes) {
+      box.resizePaddingWidget();
+    }
   }
 
   @Override
   protected void onUnload() {
     super.onUnload();
+
     if (resizeHandler != null) {
       resizeHandler.removeHandler();
       resizeHandler = null;
@@ -160,17 +196,24 @@
     Window.enableScrolling(true);
   }
 
-  private void display(DiffInfo diff) {
-    cmA = displaySide(diff.meta_a(), diff.text_a(), diffTable.getCmA());
-    cmB = displaySide(diff.meta_b(), diff.text_b(), diffTable.getCmB());
-    render(diff);
-    resizeCallbacks = new ArrayList<Runnable>();
-    renderComments(published, false);
-    renderComments(drafts, true);
+  private void display(DiffInfo diffInfo) {
+    cmA = displaySide(diffInfo.meta_a(), diffInfo.text_a(), diffTable.getCmA());
+    cmB = displaySide(diffInfo.meta_b(), diffInfo.text_b(), diffTable.getCmB());
+    render(diffInfo);
+    initialBoxes = new ArrayList<CommentBox>();
+    publishedMap = new HashMap<String, PublishedBox>(published.length());
+    lineBoxMapA = new HashMap<Integer, CommentBox>();
+    lineBoxMapB = new HashMap<Integer, CommentBox>();
+    renderPublished();
+    renderDrafts();
     published = null;
     drafts = null;
-    mapper = null;
-
+    cmA.on("cursorActivity", updateActiveLine(cmA));
+    cmB.on("cursorActivity", updateActiveLine(cmB));
+    if (Gerrit.isSignedIn()) {
+      cmA.addKeyMap(KeyMap.create().on("'c'", insertNewDraft(cmA)));
+      cmB.addKeyMap(KeyMap.create().on("'c'", insertNewDraft(cmB)));
+    }
     // TODO: Probably need horizontal resize
     resizeHandler = Window.addResizeHandler(new ResizeHandler() {
       @Override
@@ -239,32 +282,88 @@
     }
   }
 
-  private void renderComments(JsArray<CommentInfo> comments, boolean isDraft) {
-    Configuration config = Configuration.create().set("coverGutter", true);
-    for (int i = 0; comments != null && i < comments.length(); i++) {
-      CommentInfo info = comments.get(i);
-      Side mySide = info.side();
-      CodeMirror cm = mySide == Side.PARENT ? cmA : cmB;
-      CodeMirror other = otherCM(cm);
-      final CommentBox box = new CommentBox(info.author(), info.updated(),
-          info.message(), isDraft);
-      int line = info.line() - 1; // CommentInfo is 1-based, but CM is 0-based
-      diffTable.add(box);
-      cm.addLineWidget(line, box.getElement(), config);
-      int lineToPad = mapper.lineOnOther(mySide, line);
-      // Estimated height at 21px, fixed by deferring after display
-      final Element paddingOtherside = addPaddingWidget(other,
-          diffTable.style.padding(), lineToPad,
-          21, Unit.PX);
-      Runnable callback = new Runnable() {
-        @Override
-        public void run() {
-          paddingOtherside.getStyle().setHeight(
-              box.getOffsetHeight(), Unit.PX);
-        }
-      };
-      resizeCallbacks.add(callback);
-      box.setOpenCloseHandler(callback);
+  private DraftBox addNewDraft(CodeMirror cm, int line) {
+    Side side = cm == cmA ? Side.PARENT : Side.REVISION;
+    CommentInfo info = CommentInfo.create(
+        path,
+        side,
+        line + 1,
+        null,
+        null);
+    return addDraftBox(info, false);
+  }
+
+  DraftBox addReply(CommentInfo replyTo, String initMessage, boolean doSave) {
+    Side side = replyTo.side();
+    int line = replyTo.line();
+    CommentInfo info = CommentInfo.create(
+        path,
+        side,
+        line,
+        replyTo.id(),
+        initMessage);
+    return addDraftBox(info, doSave);
+  }
+
+  private DraftBox addDraftBox(CommentInfo info, boolean doSave) {
+    DraftBox box = new DraftBox(this, revision, info, commentLinkProcessor,
+        true, doSave);
+    addCommentBox(info, box);
+    if (!doSave) {
+      box.setEdit(true);
+    }
+    getLineBoxMapFromSide(info.side()).put(info.line() - 1, box);
+    return box;
+  }
+
+  CommentBox addCommentBox(CommentInfo info, final CommentBox box) {
+    diffTable.add(box);
+    Side mySide = info.side();
+    CodeMirror cm = mySide == Side.PARENT ? cmA : cmB;
+    CodeMirror other = otherCM(cm);
+    int line = info.line() - 1; // CommentInfo is 1-based, but CM is 0-based
+    LineWidget boxWidget =
+        cm.addLineWidget(line, box.getElement(), COMMENT_BOX_CONFIG);
+    int lineToPad = mapper.lineOnOther(mySide, line).getLine();
+    // Estimated height at 21px, fixed by deferring after display
+    LineWidgetElementPair padding = addPaddingWidget(
+        other, diffTable.style.padding(), lineToPad, 21, Unit.PX);
+    box.setSelfWidget(boxWidget);
+    box.setPadding(padding.widget, padding.element);
+    return box;
+  }
+
+  void removeCommentBox(Side side, int line) {
+    getLineBoxMapFromSide(side).remove(line);
+  }
+
+  private void renderPublished() {
+    for (int i = 0; published != null && i < published.length(); i++) {
+      CommentInfo info = published.get(i);
+      final PublishedBox box =
+          new PublishedBox(this, revision, info, commentLinkProcessor);
+      box.setOpen(false);
+      addCommentBox(info, box);
+      initialBoxes.add(box);
+      publishedMap.put(info.id(), box);
+      getLineBoxMapFromSide(info.side()).put(info.line() - 1, box);
+    }
+  }
+
+  private void renderDrafts() {
+    for (int i = 0; drafts != null && i < drafts.length(); i++) {
+      CommentInfo info = drafts.get(i);
+      final DraftBox box =
+          new DraftBox(this, revision, info, commentLinkProcessor, false, false);
+      box.setOpen(false);
+      box.setEdit(false);
+      addCommentBox(info, box);
+      initialBoxes.add(box);
+      PublishedBox replyToBox = publishedMap.get(info.in_reply_to());
+      if (replyToBox != null) {
+        replyToBox.registerReplyBox(box);
+      }
+      getLineBoxMapFromSide(info.side()).put(info.line() - 1, box);
     }
   }
 
@@ -272,6 +371,10 @@
     return me == cmA ? cmB : cmA;
   }
 
+  private Map<Integer, CommentBox> getLineBoxMapFromSide(Side side) {
+    return side == Side.PARENT ? lineBoxMapA : lineBoxMapB;
+  }
+
   private void markEdit(CodeMirror cm, JsArrayString lines,
       JsArray<Span> edits, int startLine) {
     if (edits == null) {
@@ -316,16 +419,16 @@
         cnt, Unit.EM);
   }
 
-  private Element addPaddingWidget(CodeMirror cm, String style, int line,
-      int height, Unit unit) {
+  private LineWidgetElementPair addPaddingWidget(CodeMirror cm, String style,
+      int line, int height, Unit unit) {
     Element div = DOM.createDiv();
     div.setClassName(style);
     div.getStyle().setHeight(height, unit);
     Configuration config = Configuration.create()
         .set("coverGutter", true)
         .set("above", line == -1);
-    cm.addLineWidget(line == -1 ? 0 : line, div, config);
-    return div;
+    LineWidget widget = cm.addLineWidget(line == -1 ? 0 : line, div, config);
+    return new LineWidgetElementPair(widget, div);
   }
 
   private Runnable doScroll(final CodeMirror cm) {
@@ -337,12 +440,74 @@
     };
   }
 
+  private Runnable updateActiveLine(final CodeMirror cm) {
+    final CodeMirror other = otherCM(cm);
+    return new Runnable() {
+      public void run() {
+        if (cm.hasActiveLine()) {
+          cm.removeLineClass(cm.getActiveLine(),
+              LineClassWhere.WRAP, diffTable.style.activeLine());
+          cm.removeLineClass(cm.getActiveLine(),
+              LineClassWhere.BACKGROUND, diffTable.style.activeLineBg());
+        }
+        if (other.hasActiveLine()) {
+          other.removeLineClass(other.getActiveLine(),
+              LineClassWhere.WRAP, diffTable.style.activeLine());
+          other.removeLineClass(other.getActiveLine(),
+              LineClassWhere.BACKGROUND, diffTable.style.activeLineBg());
+        }
+        int line = cm.getCursor("head").getLine();
+        LineOnOtherInfo info =
+            mapper.lineOnOther(cm == cmA ? Side.PARENT : Side.REVISION, line);
+        int oLine = info.getLine();
+        cm.setActiveLine(line);
+        cm.addLineClass(line, LineClassWhere.WRAP, diffTable.style.activeLine());
+        cm.addLineClass(line, LineClassWhere.BACKGROUND, diffTable.style.activeLineBg());
+        if (info.isAligned()) {
+          other.setActiveLine(oLine);
+          other.addLineClass(oLine, LineClassWhere.WRAP,
+              diffTable.style.activeLine());
+          other.addLineClass(oLine, LineClassWhere.BACKGROUND,
+              diffTable.style.activeLineBg());
+        }
+      }
+    };
+  }
+
+  private Runnable insertNewDraft(final CodeMirror cm) {
+    return new Runnable() {
+      public void run() {
+        Map<Integer, CommentBox> lineBoxMap = cm == cmA ?
+            lineBoxMapA : lineBoxMapB;
+        int line = cm.getActiveLine();
+        CommentBox box = lineBoxMap.get(line);
+        if (box == null) {
+          lineBoxMap.put(line, addNewDraft(cm, line));
+        } else if (box.isDraft()) {
+          ((DraftBox) lineBoxMap.get(line)).setEdit(true);
+        } else {
+          ((PublishedBox) box).onReply(null);
+        }
+      }
+    };
+  }
+
   private static String getContentType(DiffInfo.FileMeta meta) {
     return meta != null && meta.content_type() != null
         ? ModeInjector.getContentType(meta.content_type())
         : null;
   }
 
+  private static class LineWidgetElementPair {
+    private LineWidget widget;
+    private Element element;
+
+    private LineWidgetElementPair(LineWidget w, Element e) {
+      widget = w;
+      element = e;
+    }
+  }
+
   static class EditIterator {
     private final JsArrayString lines;
     private final int startLine;
@@ -367,6 +532,9 @@
         }
         numOfChar -= lengthWithNewline;
         advanceLine();
+        if (numOfChar == 0) {
+          return LineCharacter.create(startLine + currLineIndex, 0);
+        }
       }
       throw new IllegalStateException("EditIterator index out of bound");
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
index 879d16a..3ba3a0f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.client.diff;
 
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.account.AccountInfo;
-import com.google.gwt.core.client.GWT;
+import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.Scheduler;
+import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.shared.HandlerRegistration;
@@ -26,57 +28,54 @@
 import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
 import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.HTML;
 import com.google.gwt.user.client.ui.Widget;
+import com.google.gwtexpui.safehtml.client.SafeHtml;
+import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
+
+import net.codemirror.lib.LineWidget;
 
 import java.sql.Timestamp;
 
-/** An HtmlPanel holding the DialogBox to display a comment */
-class CommentBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, CommentBox> {}
-  private static Binder uiBinder = GWT.create(Binder.class);
-
+/** An HtmlPanel for displaying a comment */
+abstract class CommentBox extends Composite {
   interface CommentBoxStyle extends CssResource {
     String open();
     String close();
   }
 
+  private CommentLinkProcessor commentLinkProcessor;
   private HandlerRegistration headerClick;
-  private Runnable clickCallback;
+  private CommentInfo original;
+  private PatchSet.Id patchSetId;
+  private LineWidget selfWidget;
+  private LineWidget paddingWidget;
+  private Element paddingWidgetEle;
+  private CodeMirrorDemo diffView;
+  private boolean draft;
+
+  @UiField(provided=true)
+  CommentBoxHeader header;
 
   @UiField
-  Widget header;
+  HTML contentPanelMessage;
 
   @UiField
-  AvatarImage avatar;
+  CommentBoxResources res;
 
-  @UiField
-  Element name;
-
-  @UiField
-  Element summary;
-
-  @UiField
-  Element date;
-
-  @UiField
-  Element contentPanel;
-
-  @UiField
-  Element contentPanelMessage;
-
-  @UiField
-  CommentBoxStyle style;
-
-  CommentBox(AccountInfo author, Timestamp when, String message,
+  protected CommentBox(
+      CodeMirrorDemo host,
+      UiBinder<? extends Widget, CommentBox> binder,
+      PatchSet.Id id, CommentInfo info, CommentLinkProcessor linkProcessor,
       boolean isDraft) {
-    initWidget(uiBinder.createAndBindUi(this));
-    // TODO: Format the comment box differently based on whether isDraft
-    // is true.
-    setAuthorNameText(author);
-    date.setInnerText(FormatUtil.shortFormatDayTime(when));
-    setMessageText(message);
-    setOpen(false);
+    diffView = host;
+    commentLinkProcessor = linkProcessor;
+    original = info;
+    patchSetId = id;
+    draft = isDraft;
+    header = new CommentBoxHeader(info.author(), info.updated(), isDraft);
+    initWidget(binder.createAndBindUi(this));
+    setMessageText(info.message());
   }
 
   @Override
@@ -87,15 +86,9 @@
       @Override
       public void onClick(ClickEvent event) {
         setOpen(!isOpen());
-        if (clickCallback != null) {
-          clickCallback.run();
-        }
       }
     }, ClickEvent.getType());
-  }
-
-  void setOpenCloseHandler(final Runnable callback) {
-    clickCallback = callback;
+    res.style().ensureInjected();
   }
 
   @Override
@@ -108,34 +101,82 @@
     }
   }
 
-  private void setAuthorNameText(AccountInfo author) {
-    // TODO: Set avatar's display to none if we get a 404.
-    avatar = new AvatarImage(author, 26);
-    name.setInnerText(FormatUtil.name(author));
+  void setSelfWidget(LineWidget widget) {
+    selfWidget = widget;
   }
 
-  private void setMessageText(String message) {
+  void setPadding(LineWidget widget, Element element) {
+    paddingWidget = widget;
+    paddingWidgetEle = element;
+  }
+
+  void resizePaddingWidget() {
+    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
+      @Override
+      public void execute() {
+        paddingWidgetEle.getStyle().setHeight(getOffsetHeight(), Unit.PX);
+        paddingWidget.changed();
+        selfWidget.changed();
+      }
+    });
+  }
+
+  protected void setMessageText(String message) {
     if (message == null) {
       message = "";
     } else {
       message = message.trim();
     }
-    summary.setInnerText(message);
-    // TODO: Change to use setInnerHtml
-    contentPanelMessage.setInnerText(message);
+    header.setSummaryText(message);
+    SafeHtml buf = new SafeHtmlBuilder().append(message).wikify();
+    buf = commentLinkProcessor.apply(buf);
+    SafeHtml.set(contentPanelMessage, buf);
   }
 
-  private void setOpen(boolean open) {
+  protected void setDate(Timestamp when) {
+    header.setDate(when);
+  }
+
+  protected void setOpen(boolean open) {
     if (open) {
-      removeStyleName(style.close());
-      addStyleName(style.open());
+      removeStyleName(res.style().close());
+      addStyleName(res.style().open());
     } else {
-      removeStyleName(style.open());
-      addStyleName(style.close());
+      removeStyleName(res.style().open());
+      addStyleName(res.style().close());
     }
+    resizePaddingWidget();
   }
 
   private boolean isOpen() {
-    return getStyleName().contains(style.open());
+    return getStyleName().contains(res.style().open());
+  }
+
+  protected CodeMirrorDemo getDiffView() {
+    return diffView;
+  }
+
+  protected PatchSet.Id getPatchSetId() {
+    return patchSetId;
+  }
+
+  protected CommentInfo getOriginal() {
+    return original;
+  }
+
+  protected LineWidget getSelfWidget() {
+    return selfWidget;
+  }
+
+  protected LineWidget getPaddingWidget() {
+    return paddingWidget;
+  }
+
+  protected void updateOriginal(CommentInfo newInfo) {
+    original = newInfo;
+  }
+
+  boolean isDraft() {
+    return draft;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.ui.xml
deleted file mode 100644
index 38dcd65..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.ui.xml
+++ /dev/null
@@ -1,77 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:c='urn:import:com.google.gerrit.client'>
-  <ui:style type='com.google.gerrit.client.diff.CommentBox.CommentBoxStyle'>
-    .commentBox {
-      background-color: #e5ecf9;
-      border: 1px solid black;
-    }
-    .table {
-      width: 100%;
-      cursor: pointer;
-      table-layout: fixed;
-    }
-    .summary {
-      width: 60%;
-    }
-    .summaryText {
-      color: #777;
-      height: 1em;
-      max-width: 80%;
-      overflow: hidden;
-      padding-bottom: 1px;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    .open .summaryText {
-      display: none;
-    }
-    .date {
-      width: 25%;
-      text-align: right;
-    }
-    .close .contentPanel {
-      display: none;
-    }
-    .message {
-      margin: 5px;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.commentBox}'>
-    <g:HTMLPanel ui:field='header'>
-    <table class='{style.table}'>
-      <tr>
-        <td><c:AvatarImage ui:field='avatar' /></td>
-        <td ui:field='name'></td>
-        <td class='{style.summary}'>
-          <div ui:field='summary' class='{style.summaryText}'></div>
-        </td>
-        <td ui:field='date' class='{style.date}'></td>
-      </tr>
-    </table>
-    </g:HTMLPanel>
-    <div ui:field='contentPanel' class='{style.contentPanel}'>
-      <div><p ui:field='contentPanelMessage' class='{style.message}'></p></div>
-      <div>
-        <button ui:field='reply'><ui:msg>Reply ...</ui:msg></button>
-        <button ui:field='replyDone'><ui:msg>Reply 'Done'</ui:msg></button>
-      </div>
-    </div>
-  </g:HTMLPanel>
-</ui:UiBinder>
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxHeader.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxHeader.java
new file mode 100644
index 0000000..5ffd925
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxHeader.java
@@ -0,0 +1,79 @@
+//Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.client.AvatarImage;
+import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.account.AccountInfo;
+import com.google.gerrit.client.patches.PatchUtil;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.HTMLPanel;
+
+import java.sql.Timestamp;
+
+/**
+ * An HtmlPanel representing the header of a CommentBox, displaying
+ * the author's avatar (if applicable), the author's name, the summary,
+ * and the date.
+ */
+class CommentBoxHeader extends Composite {
+  interface Binder extends UiBinder<HTMLPanel, CommentBoxHeader> {}
+  private static Binder uiBinder = GWT.create(Binder.class);
+
+  private boolean draft;
+
+  @UiField(provided=true)
+  AvatarImage avatar;
+
+  @UiField
+  Element name;
+
+  @UiField
+  Element summary;
+
+  @UiField
+  Element date;
+
+  CommentBoxHeader(AccountInfo author, Timestamp when, boolean isDraft) {
+    // TODO: Set avatar's display to none if we get a 404.
+    avatar = author == null ? new AvatarImage() : new AvatarImage(author, 26);
+    initWidget(uiBinder.createAndBindUi(this));
+    this.draft = isDraft;
+    if (when != null) {
+      setDate(when);
+    }
+    if (isDraft) {
+      name.setInnerText("(Draft)");
+    } else {
+      name.setInnerText(FormatUtil.name(author));
+    }
+  }
+
+  void setDate(Timestamp when) {
+    if (draft) {
+      date.setInnerText(PatchUtil.M.draftSaved(when));
+    } else {
+      date.setInnerText(FormatUtil.shortFormatDayTime(when));
+    }
+  }
+
+  void setSummaryText(String message) {
+    summary.setInnerText(message);
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxHeader.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxHeader.ui.xml
new file mode 100644
index 0000000..64ae22b
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxHeader.ui.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:c='urn:import:com.google.gerrit.client'>
+  <ui:with field='res' type='com.google.gerrit.client.diff.CommentBoxResources' />
+    <g:HTMLPanel>
+    <table class='{res.style.table}'>
+      <tr>
+        <td><c:AvatarImage ui:field='avatar' /></td>
+        <td ui:field='name'></td>
+        <td class='{res.style.summary}'>
+          <div ui:field='summary' class='{res.style.summaryText}'></div>
+        </td>
+        <td ui:field='date' class='{res.style.date}'></td>
+      </tr>
+    </table>
+    </g:HTMLPanel>
+</ui:UiBinder>
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxResources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxResources.java
new file mode 100644
index 0000000..e08eec5
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxResources.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gwt.resources.client.ClientBundle;
+import com.google.gwt.resources.client.CssResource;
+
+/**
+ * Resources used by diff.
+ */
+interface CommentBoxResources extends ClientBundle {
+  @Source("CommentBoxUi.css")
+  Style style();
+
+  interface Style extends CssResource {
+    String open();
+    String close();
+    String commentBox();
+    String table();
+    String summary();
+    String summaryText();
+    String date();
+    String contentPanel();
+    String message();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxUi.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxUi.css
new file mode 100644
index 0000000..4af0157
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBoxUi.css
@@ -0,0 +1,41 @@
+.commentBox {
+  background-color: #e5ecf9;
+  border: 1px solid black;
+}
+
+.table {
+  width: 100%;
+  cursor: pointer;
+  table-layout: fixed;
+}
+
+.summary {
+  width: 60%;
+}
+
+.summaryText {
+  color: #777;
+  height: 1em;
+  max-width: 80%;
+  overflow: hidden;
+  padding-bottom: 1px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.open .summaryText {
+  display: none;
+}
+
+.date {
+  width: 25%;
+  text-align: right;
+}
+
+.close .contentPanel {
+  display: none;
+}
+
+.message {
+  margin: 5px;
+}
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
index ee556c5..da0a450 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.user.client.rpc.AsyncCallback;
@@ -24,6 +25,13 @@
     NONE, TRAILING, CHANGED, ALL;
   };
 
+  public static void list(int id, String revision,
+      AsyncCallback<NativeMap<FileInfo>> cb) {
+    ChangeApi.revision(id, revision)
+      .view("files")
+      .get(NativeMap.copyKeysIntoChildren("path", cb));
+  }
+
   public static DiffApi diff(PatchSet.Id id, String path) {
     return new DiffApi(ChangeApi.revision(id)
         .view("files").id(path)
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
index a177498..0e22876 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
@@ -35,6 +35,8 @@
     String diff();
     String intraline();
     String padding();
+    String activeLine();
+    String activeLineBg();
   }
 
   @UiField
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.ui.xml
index 0e303bc..7608267 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.ui.xml
@@ -57,6 +57,12 @@
     .a .CodeMirror-vscrollbar {
       display: none !important;
     }
+    .activeLine .CodeMirror-linenumber {
+      background-color: #D8EDF9;
+    }
+    .activeLineBg {
+      background-color: #D8EDF9;
+    }
   </ui:style>
   <g:HTMLPanel styleName='{style.difftable}'>
     <table class='{style.table}'>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
new file mode 100644
index 0000000..c4906de
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
@@ -0,0 +1,201 @@
+//Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.client.changes.CommentApi;
+import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.changes.CommentInput;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.DoubleClickEvent;
+import com.google.gwt.event.dom.client.DoubleClickHandler;
+import com.google.gwt.event.dom.client.MouseMoveEvent;
+import com.google.gwt.event.dom.client.MouseMoveHandler;
+import com.google.gwt.event.shared.HandlerRegistration;
+import com.google.gwt.resources.client.CssResource;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwtexpui.globalkey.client.NpTextArea;
+
+/** An HtmlPanel for displaying and editing a draft */
+class DraftBox extends CommentBox {
+  interface Binder extends UiBinder<HTMLPanel, DraftBox> {}
+  private static UiBinder<HTMLPanel, CommentBox> uiBinder =
+      GWT.create(Binder.class);
+
+  interface DraftBoxStyle extends CssResource {
+    String edit();
+    String view();
+    String newDraft();
+  }
+
+  @UiField
+  NpTextArea editArea;
+
+  @UiField
+  DraftBoxStyle draftStyle;
+
+  @UiField
+  Button edit;
+
+  @UiField
+  Button save;
+
+  @UiField
+  Button cancel;
+
+  @UiField
+  Button discard;
+
+  private HandlerRegistration messageClick;
+  private boolean isNew;
+  private PublishedBox replyToBox;
+
+  DraftBox(
+      CodeMirrorDemo host,
+      PatchSet.Id id,
+      CommentInfo info,
+      CommentLinkProcessor linkProcessor,
+      boolean isNew,
+      boolean saveOnInit) {
+    super(host, uiBinder, id, info, linkProcessor, true);
+
+    this.isNew = isNew;
+    editArea.setText(contentPanelMessage.getText());
+    if (saveOnInit) {
+      onSave(null);
+    }
+    if (isNew) {
+      addStyleName(draftStyle.newDraft());
+    }
+  }
+
+  @Override
+  protected void onLoad() {
+    super.onLoad();
+
+    messageClick = contentPanelMessage.addDoubleClickHandler(
+        new DoubleClickHandler() {
+      @Override
+      public void onDoubleClick(DoubleClickEvent arg0) {
+        setEdit(true);
+      }
+    });
+    addDomHandler(new MouseMoveHandler() {
+      @Override
+      public void onMouseMove(MouseMoveEvent arg0) {
+        resizePaddingWidget();
+      }
+    }, MouseMoveEvent.getType());
+  }
+
+  @Override
+  protected void onUnload() {
+    super.onUnload();
+
+    messageClick.removeHandler();
+    messageClick = null;
+  }
+
+  void setEdit(boolean edit) {
+    if (edit) {
+      setOpen(true);
+      removeStyleName(draftStyle.view());
+      addStyleName(draftStyle.edit());
+      editArea.setText(contentPanelMessage.getText());
+      editArea.setFocus(true);
+    } else {
+      removeStyleName(draftStyle.edit());
+      addStyleName(draftStyle.view());
+    }
+    resizePaddingWidget();
+  }
+
+  void registerReplyToBox(PublishedBox box) {
+    replyToBox = box;
+  }
+
+  private void removeUI() {
+    if (replyToBox != null) {
+      replyToBox.unregisterReplyBox();
+    }
+    CommentInfo info = getOriginal();
+    getDiffView().removeCommentBox(info.side(), info.line() - 1);
+    removeFromParent();
+    getSelfWidget().clear();
+    getPaddingWidget().clear();
+  }
+
+  @UiHandler("edit")
+  void onEdit(ClickEvent e) {
+    setEdit(true);
+  }
+
+  @UiHandler("save")
+  void onSave(ClickEvent e) {
+    final String message = editArea.getText();
+    if (message.equals("")) {
+      return;
+    }
+    CommentInfo original = getOriginal();
+    CommentInput input = CommentInput.create(original);
+    input.setMessage(message);
+    GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
+      @Override
+      public void onSuccess(CommentInfo result) {
+        updateOriginal(result);
+        setMessageText(message);
+        setDate(result.updated());
+        setEdit(false);
+        if (isNew) {
+          removeStyleName(draftStyle.newDraft());
+          isNew = false;
+        }
+      }
+    };
+    if (isNew) {
+      CommentApi.createDraft(getPatchSetId(), input, cb);
+    } else {
+      CommentApi.updateDraft(getPatchSetId(), original.id(), input, cb);
+    }
+  }
+
+  @UiHandler("cancel")
+  void onCancel(ClickEvent e) {
+    setEdit(false);
+  }
+
+  @UiHandler("discard")
+  void onDiscard(ClickEvent e) {
+    if (isNew) {
+      removeUI();
+    } else {
+      CommentApi.deleteDraft(getPatchSetId(), getOriginal().id(),
+          new GerritCallback<JavaScriptObject>() {
+        @Override
+        public void onSuccess(JavaScriptObject result) {
+          removeUI();
+        }
+      });
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.ui.xml
new file mode 100644
index 0000000..d864406
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.ui.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:d='urn:import:com.google.gerrit.client.diff'
+    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'>
+  <ui:with field='res' type='com.google.gerrit.client.diff.CommentBoxResources' />
+  <ui:style field='draftStyle' type='com.google.gerrit.client.diff.DraftBox.DraftBoxStyle'>
+    .edit .messagePanel {
+      display: none;
+    }
+    .view .editArea {
+      display: none;
+    }
+    .newDraft .cancel {
+      display: none;
+    }
+  </ui:style>
+  <g:HTMLPanel styleName='{res.style.commentBox}'>
+    <d:CommentBoxHeader ui:field='header' />
+    <div class='{res.style.contentPanel}'>
+      <c:NpTextArea ui:field='editArea' styleName='{draftStyle.editArea}'/>
+      <div>
+        <g:Button ui:field='save' styleName='{draftStyle.editArea}'>
+          <ui:msg>Save</ui:msg>
+        </g:Button>
+        <g:Button ui:field='cancel'
+            styleName='{draftStyle.editArea} {draftStyle.cancel}'>
+          <ui:msg>Cancel</ui:msg>
+        </g:Button>
+        <g:Button ui:field='discard' styleName='{draftStyle.editArea}'>
+          <ui:msg>Discard</ui:msg>
+        </g:Button>
+      </div>
+    </div>
+    <div class='{res.style.contentPanel}'>
+      <g:HTML ui:field='contentPanelMessage'
+          styleName='{res.style.message} {draftStyle.messagePanel}'></g:HTML>
+      <g:Button ui:field='edit' styleName='{draftStyle.messagePanel}'>
+        <ui:msg>Edit</ui:msg>
+      </g:Button>
+    </div>
+  </g:HTMLPanel>
+</ui:UiBinder>
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/FileInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/FileInfo.java
new file mode 100644
index 0000000..cc73a94
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/FileInfo.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+public class FileInfo extends JavaScriptObject {
+  public final native String path() /*-{ return this.path; }-*/;
+  public final native String old_path() /*-{ return this.old_path; }-*/;
+  public final native int lines_inserted() /*-{ return this.lines_inserted || 0; }-*/;
+  public final native int lines_deleted() /*-{ return this.lines_deleted || 0; }-*/;
+  public final native boolean binary() /*-{ return this.binary || false; }-*/;
+
+  protected FileInfo() {
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
index b86f59f..aed0813 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
@@ -49,7 +49,7 @@
     int origLineB = lineB;
     lineB += numLines;
     int bAheadOfA = lineB - lineA;
-    lineMapAtoB.add(new LineGap(lineA, lineA, bAheadOfA));
+    lineMapAtoB.add(new LineGap(lineA, -1, bAheadOfA));
     lineMapBtoA.add(new LineGap(origLineB, lineB - 1, -bAheadOfA));
   }
 
@@ -58,7 +58,7 @@
     lineA += numLines;
     int aAheadOfB = lineA - lineB;
     lineMapAtoB.add(new LineGap(origLineA, lineA - 1, -aAheadOfB));
-    lineMapBtoA.add(new LineGap(lineB, lineB, aAheadOfB));
+    lineMapBtoA.add(new LineGap(lineB, -1, aAheadOfB));
   }
 
   /**
@@ -69,8 +69,9 @@
    *
    * A LineGap records gap information from the start of an actual gap up to
    * the start of the next gap. In the following example,
-   * lineMapAtoB will have LineGap: {start: 1, end: 1, delta: 3}
-   * (end doesn't really matter here, as the binary search only looks at start)
+   * lineMapAtoB will have LineGap: {start: 1, end: -1, delta: 3}
+   * (end set to -1 to represent a dummy gap of length zero. The binary search
+   * only looks at start so setting it to -1 has no effect here.)
    * lineMapBtoA will have LineGap: {start: 1, end: 3, delta: -3}
    * These LineGaps control lines between 1 and 5.
    *
@@ -97,25 +98,62 @@
    *   -   |   6
    *      ...
    */
-  int lineOnOther(Side mySide, int line) {
+  LineOnOtherInfo lineOnOther(Side mySide, int line) {
     List<LineGap> lineGaps = mySide == Side.PARENT ? lineMapAtoB : lineMapBtoA;
     // Create a dummy LineGap for the search.
     int ret = Collections.binarySearch(lineGaps, new LineGap(line));
     if (ret == -1) {
-      return line;
+      return new LineOnOtherInfo(line, true);
     } else {
       LineGap lookup = lineGaps.get(0 <= ret ? ret : -ret - 2);
+      int start = lookup.start;
       int end = lookup.end;
       int delta = lookup.delta;
-      if (lookup.start <= line && line <= end) { // Line falls within gap
-        return end + delta;
+      if (start <= line && line <= end && end != -1) { // Line falls within gap
+        return new LineOnOtherInfo(end + delta, false);
       } else { // Line after gap
-        return line + delta;
+        return new LineOnOtherInfo(line + delta, true);
       }
     }
   }
 
   /**
+   * @field line The line number on the other side.
+   * @field aligned Whether the two lines are at the same height when displayed.
+   */
+  static class LineOnOtherInfo {
+    private int line;
+    private boolean aligned;
+
+    LineOnOtherInfo(int line, boolean aligned) {
+      this.line = line;
+      this.aligned = aligned;
+    }
+
+    int getLine() {
+      return line;
+    }
+
+    boolean isAligned() {
+      return aligned;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (obj instanceof LineOnOtherInfo) {
+        LineOnOtherInfo other = (LineOnOtherInfo) obj;
+        return aligned == other.aligned && line == other.line;
+      }
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      return line + " " + aligned;
+    }
+  }
+
+  /**
    * Helper class to record line gap info and assist in calculation of line
    * number on the other side.
    *
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
new file mode 100644
index 0000000..337b528
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
@@ -0,0 +1,72 @@
+//Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.ui.HTMLPanel;
+
+/** An HtmlPanel for displaying a published comment */
+class PublishedBox extends CommentBox {
+  interface Binder extends UiBinder<HTMLPanel, PublishedBox> {}
+  private static UiBinder<HTMLPanel, CommentBox> uiBinder =
+      GWT.create(Binder.class);
+
+  private DraftBox replyBox;
+
+  PublishedBox(CodeMirrorDemo host, PatchSet.Id id, CommentInfo info,
+      CommentLinkProcessor linkProcessor) {
+    super(host, uiBinder, id, info, linkProcessor, false);
+  }
+
+  void registerReplyBox(DraftBox box) {
+    replyBox = box;
+    box.registerReplyToBox(this);
+  }
+
+  void unregisterReplyBox() {
+    replyBox = null;
+  }
+
+  private void openReplyBox() {
+    replyBox.setOpen(true);
+    replyBox.setEdit(true);
+  }
+
+  @UiHandler("reply")
+  void onReply(ClickEvent e) {
+    if (replyBox == null) {
+      DraftBox box = getDiffView().addReply(getOriginal(), "", false);
+      registerReplyBox(box);
+    } else {
+      openReplyBox();
+    }
+  }
+
+  @UiHandler("replyDone")
+  void onReplyDone(ClickEvent e) {
+    if (replyBox == null) {
+      DraftBox box = getDiffView().addReply(getOriginal(), "Done", true);
+      registerReplyBox(box);
+    } else {
+      openReplyBox();
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
new file mode 100644
index 0000000..a146e82
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:d='urn:import:com.google.gerrit.client.diff'>
+  <ui:with field='res' type='com.google.gerrit.client.diff.CommentBoxResources' />
+  <g:HTMLPanel styleName='{res.style.commentBox}'>
+    <d:CommentBoxHeader ui:field='header' />
+    <g:HTMLPanel styleName='{res.style.contentPanel}'>
+      <g:HTML ui:field='contentPanelMessage' styleName='{res.style.message}'></g:HTML>
+      <div>
+        <g:Button ui:field='reply'><ui:msg>Reply ...</ui:msg></g:Button>
+        <g:Button ui:field='replyDone'><ui:msg>Reply 'Done'</ui:msg></g:Button>
+      </div>
+    </g:HTMLPanel>
+  </g:HTMLPanel>
+</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
index 3b4abe5..f529990 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
@@ -26,7 +26,7 @@
   }
 
   public final AccountGroup.UUID getGroupUUID() {
-    return new AccountGroup.UUID(URL.decodePathSegment(id()));
+    return new AccountGroup.UUID(URL.decodeQueryString(id()));
   }
 
   public final native String id() /*-{ return this.id; }-*/;
@@ -46,13 +46,13 @@
   public final AccountGroup.UUID getOwnerUUID() {
     String owner = owner_id();
     if (owner != null) {
-        return new AccountGroup.UUID(URL.decodePathSegment(owner));
+        return new AccountGroup.UUID(URL.decodeQueryString(owner));
     }
     return null;
   }
 
   public final void setOwnerUUID(AccountGroup.UUID uuid) {
-    owner_id(URL.encodePathSegment(uuid.get()));
+    owner_id(URL.encodeQueryString(uuid.get()));
   }
 
   protected GroupInfo() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/NativeMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
index d56eeaf..a971c51 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
@@ -80,6 +80,7 @@
   }
 
   public final native T get(String n) /*-{ return this[n]; }-*/;
+  public final native void put(String n, T v) /*-{ this[n] = v; }-*/;
 
   public final native void copyKeysIntoChildren(String p)
   /*-{
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
index afbd740..ce417bb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
@@ -195,7 +195,8 @@
       final Element tr = DOM.getParent(fmt.getElement(currentRow, C_ARROW));
       UIObject.setStyleName(tr, Gerrit.RESOURCES.css().activeRow(), false);
     }
-    if (newRow >= 0) {
+    if (0 <= newRow && newRow < table.getRowCount()
+        && getRowItem(newRow) != null) {
       table.setWidget(newRow, C_ARROW, pointer);
       final Element tr = DOM.getParent(fmt.getElement(newRow, C_ARROW));
       UIObject.setStyleName(tr, Gerrit.RESOURCES.css().activeRow(), true);
@@ -255,7 +256,7 @@
   }
 
   @Override
-  protected void resetHtml(SafeHtml body) {
+  public void resetHtml(SafeHtml body) {
     currentRow = -1;
     super.resetHtml(body);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
index a26db05..3f79a61 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
@@ -48,6 +48,7 @@
   protected Screen() {
     initWidget(new FlowPanel());
     setStyleName(Gerrit.RESOURCES.css().screen());
+    body = new FlowPanel();
   }
 
   @Override
@@ -76,7 +77,7 @@
   protected void onInitUI() {
     final FlowPanel me = (FlowPanel) getWidget();
     me.add(header = new Grid(1, Cols.values().length));
-    me.add(body = new FlowPanel());
+    me.add(body);
 
     headerText = new InlineLabel();
     if (titleWidget == null) {
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
index dbf1a2b..69d1671 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
@@ -66,6 +66,16 @@
     this.addLineClass(line, where, lineClass);
   }-*/;
 
+  public final void removeLineClass(int line, LineClassWhere where,
+      String className) {
+    removeLineClassNative(line, where.name().toLowerCase(), className);
+  }
+
+  private final native void removeLineClassNative(int line, String where,
+      String lineClass) /*-{
+    this.removeLineClass(line, where, lineClass);
+  }-*/;
+
   public final native void addWidget(LineCharacter pos, Element node,
       boolean scrollIntoView) /*-{
     this.addWidget(pos, node, scrollIntoView);
@@ -102,6 +112,24 @@
     });
   }-*/;
 
+  public final native LineCharacter getCursor(String start) /*-{
+    return this.getCursor(start);
+  }-*/;
+
+  public final native boolean hasActiveLine() /*-{
+    return this.state.hasOwnProperty('activeLine');
+  }-*/;
+
+  public final native int getActiveLine() /*-{
+    return this.state.activeLine;
+  }-*/;
+
+  public final native void setActiveLine(int line) /*-{
+    this.state.activeLine = line;
+  }-*/;
+
+  public final native void addKeyMap(KeyMap map) /*-{ this.addKeyMap(map); }-*/;
+
   protected CodeMirror() {
   }
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java
new file mode 100644
index 0000000..ac2ca5c
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.lib;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+/** Object that associates a key or key combination with a handler. */
+public class KeyMap extends JavaScriptObject {
+  public static KeyMap create() {
+    return createObject().cast();
+  }
+
+  public final native KeyMap on(String key, Runnable thunk) /*-{
+    this[key] = function() { $entry(thunk.@java.lang.Runnable::run()()); };
+    return this;
+  }-*/;
+
+  protected KeyMap() {
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/LineCharacter.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/LineCharacter.java
index 9a7b64a..c1e5469 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/LineCharacter.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/LineCharacter.java
@@ -25,8 +25,8 @@
     return lineCh;
   }
 
-  public final native void setLine(int line) /*-{ this.line = line; }-*/;
-  public final native void setCh(int ch) /*-{ this.ch = ch; }-*/;
+  private final native void setLine(int line) /*-{ this.line = line; }-*/;
+  private final native void setCh(int ch) /*-{ this.ch = ch; }-*/;
 
   public final native int getLine() /*-{ return this.line; }-*/;
   public final native int getCh() /*-{ return this.ch; }-*/;
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
index 74405e9..96ba2b7 100644
--- a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
+++ b/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
@@ -16,6 +16,7 @@
 
 import static org.junit.Assert.assertEquals;
 
+import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
 import com.google.gerrit.common.changes.Side;
 
 import org.junit.Test;
@@ -51,41 +52,55 @@
   public void testFindInCommon() {
     LineMapper mapper = new LineMapper();
     mapper.appendCommon(10);
-    assertEquals(9, mapper.lineOnOther(Side.PARENT, 9));
+    assertEquals(new LineOnOtherInfo(9, true),
+        mapper.lineOnOther(Side.PARENT, 9));
+    assertEquals(new LineOnOtherInfo(9, true),
+        mapper.lineOnOther(Side.REVISION, 9));
   }
 
   @Test
   public void testFindAfterCommon() {
     LineMapper mapper = new LineMapper();
     mapper.appendCommon(10);
-    assertEquals(10, mapper.lineOnOther(Side.PARENT, 10));
+    assertEquals(new LineOnOtherInfo(10, true),
+        mapper.lineOnOther(Side.PARENT, 10));
+    assertEquals(new LineOnOtherInfo(10, true),
+        mapper.lineOnOther(Side.REVISION, 10));
   }
 
   @Test
   public void testFindInInsertGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendInsert(10);
-    assertEquals(-1, mapper.lineOnOther(Side.REVISION, 9));
+    assertEquals(new LineOnOtherInfo(-1, false),
+        mapper.lineOnOther(Side.REVISION, 9));
   }
 
   @Test
   public void testFindAfterInsertGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendInsert(10);
-    assertEquals(0, mapper.lineOnOther(Side.REVISION, 10));
+    assertEquals(new LineOnOtherInfo(0, true),
+        mapper.lineOnOther(Side.REVISION, 10));
+    assertEquals(new LineOnOtherInfo(10, true),
+        mapper.lineOnOther(Side.PARENT, 0));
   }
 
   @Test
   public void testFindInDeleteGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendDelete(10);
-    assertEquals(-1, mapper.lineOnOther(Side.PARENT, 9));
+    assertEquals(new LineOnOtherInfo(-1, false),
+        mapper.lineOnOther(Side.PARENT, 9));
   }
 
   @Test
   public void testFindAfterDeleteGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendDelete(10);
-    assertEquals(0, mapper.lineOnOther(Side.PARENT, 10));
+    assertEquals(new LineOnOtherInfo(0, true),
+        mapper.lineOnOther(Side.PARENT, 10));
+    assertEquals(new LineOnOtherInfo(10, true),
+        mapper.lineOnOther(Side.REVISION, 0));
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
index ff65674..e0bef35 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -77,7 +77,7 @@
     String runas = req.getHeader(RUN_AS);
     if (runas != null) {
       if (!enabled) {
-        RestApiServlet.replyError(res,
+        RestApiServlet.replyError(req, res,
             SC_FORBIDDEN,
             RUN_AS + " disabled by auth.enableRunAs = false");
         return;
@@ -85,7 +85,7 @@
 
       CurrentUser self = session.get().getCurrentUser();
       if (!self.getCapabilities().canRunAs()) {
-        RestApiServlet.replyError(res,
+        RestApiServlet.replyError(req, res,
             SC_FORBIDDEN,
             "not permitted to use " + RUN_AS);
         return;
@@ -96,13 +96,13 @@
         target = accountResolver.find(runas);
       } catch (OrmException e) {
         log.warn("cannot resolve account for " + RUN_AS, e);
-        RestApiServlet.replyError(res,
+        RestApiServlet.replyError(req, res,
             SC_INTERNAL_SERVER_ERROR,
             "cannot resolve " + RUN_AS);
         return;
       }
       if (target == null) {
-        RestApiServlet.replyError(res,
+        RestApiServlet.replyError(req, res,
             SC_FORBIDDEN,
             "no account matches " + RUN_AS);
         return;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 371e9ea4..9183d5c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -68,7 +68,7 @@
       clp.parseOptionMap(in);
     } catch (CmdLineException e) {
       if (!clp.wasHelpRequestedByOption()) {
-        replyError(res, SC_BAD_REQUEST, e.getMessage());
+        replyError(req, res, SC_BAD_REQUEST, e.getMessage());
         return false;
       }
     }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index fac950b..c671ec7 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -112,7 +113,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.TimeUnit;
 import java.util.regex.Pattern;
 import java.util.zip.GZIPOutputStream;
 
@@ -300,9 +300,9 @@
 
       if (result instanceof Response) {
         @SuppressWarnings("rawtypes")
-        Response r = (Response) result;
+        Response<?> r = (Response) result;
         status = r.statusCode();
-        configureCaching(req, res, r);
+        configureCaching(req, res, r.caching());
       } else if (result instanceof Response.Redirect) {
         CacheHeaders.setNotCacheable(res);
         res.sendRedirect(((Response.Redirect) result).location());
@@ -321,27 +321,27 @@
         }
       }
     } catch (AuthException e) {
-      replyError(res, status = SC_FORBIDDEN, e.getMessage());
+      replyError(req, res, status = SC_FORBIDDEN, e.getMessage(), e.caching());
     } catch (BadRequestException e) {
-      replyError(res, status = SC_BAD_REQUEST, e.getMessage());
+      replyError(req, res, status = SC_BAD_REQUEST, e.getMessage(), e.caching());
     } catch (MethodNotAllowedException e) {
-      replyError(res, status = SC_METHOD_NOT_ALLOWED, "Method not allowed");
+      replyError(req, res, status = SC_METHOD_NOT_ALLOWED, "Method not allowed", e.caching());
     } catch (ResourceConflictException e) {
-      replyError(res, status = SC_CONFLICT, e.getMessage());
+      replyError(req, res, status = SC_CONFLICT, e.getMessage(), e.caching());
     } catch (PreconditionFailedException e) {
-      replyError(res, status = SC_PRECONDITION_FAILED,
-          Objects.firstNonNull(e.getMessage(), "Precondition failed"));
+      replyError(req, res, status = SC_PRECONDITION_FAILED,
+          Objects.firstNonNull(e.getMessage(), "Precondition failed"), e.caching());
     } catch (ResourceNotFoundException e) {
-      replyError(res, status = SC_NOT_FOUND, "Not found");
+      replyError(req, res, status = SC_NOT_FOUND, "Not found", e.caching());
     } catch (UnprocessableEntityException e) {
-      replyError(res, status = 422,
-          Objects.firstNonNull(e.getMessage(), "Unprocessable Entity"));
+      replyError(req, res, status = 422,
+          Objects.firstNonNull(e.getMessage(), "Unprocessable Entity"), e.caching());
     } catch (AmbiguousViewException e) {
-      replyError(res, status = SC_NOT_FOUND, e.getMessage());
+      replyError(req, res, status = SC_NOT_FOUND, e.getMessage());
     } catch (MalformedJsonException e) {
-      replyError(res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request");
+      replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request");
     } catch (JsonParseException e) {
-      replyError(res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request");
+      replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request");
     } catch (Exception e) {
       status = SC_INTERNAL_SERVER_ERROR;
       handleException(e, req, res);
@@ -354,18 +354,18 @@
   }
 
   private static <T> void configureCaching(HttpServletRequest req,
-      HttpServletResponse res, Response<T> r) {
+      HttpServletResponse res, CacheControl c) {
     if ("GET".equals(req.getMethod())) {
-      switch (r.caching()) {
+      switch (c.getType()) {
         case NONE:
         default:
           CacheHeaders.setNotCacheable(res);
           break;
         case PRIVATE:
-          CacheHeaders.setCacheablePrivate(res, 7, TimeUnit.DAYS);
+          CacheHeaders.setCacheablePrivate(res, c.getAge(), c.getUnit());
           break;
         case PUBLIC:
-          CacheHeaders.setCacheable(req, res, 7, TimeUnit.DAYS);
+          CacheHeaders.setCacheable(req, res, c.getAge(), c.getUnit());
           break;
       }
     } else {
@@ -837,14 +837,20 @@
 
     if (!res.isCommitted()) {
       res.reset();
-      replyError(res, SC_INTERNAL_SERVER_ERROR, "Internal server error");
+      replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error");
     }
   }
 
-  public static void replyError(HttpServletResponse res, int statusCode,
-      String msg) throws IOException {
+  public static void replyError(HttpServletRequest req,
+      HttpServletResponse res, int statusCode, String msg) throws IOException {
+    replyError(req, res, statusCode, msg, CacheControl.NONE);
+  }
+
+  public static void replyError(HttpServletRequest req,
+      HttpServletResponse res, int statusCode, String msg,
+      CacheControl c) throws IOException {
     res.setStatus(statusCode);
-    CacheHeaders.setNotCacheable(res);
+    configureCaching(req, res, c);
     replyText(null, res, msg);
   }
 
diff --git a/gerrit-lucene/BUCK b/gerrit-lucene/BUCK
index 67dd218..b5e5d07 100644
--- a/gerrit-lucene/BUCK
+++ b/gerrit-lucene/BUCK
@@ -10,6 +10,7 @@
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
     '//lib:gwtorm',
+    '//lib:guava',
     '//lib/lucene:core',
   ],
   visibility = ['PUBLIC'],
@@ -26,7 +27,9 @@
     '//gerrit-server:server',
     '//lib:guava',
     '//lib:gwtorm',
+    '//lib:jsr305',
     '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
     '//lib/jgit:jgit',
     '//lib/log:api',
     '//lib/lucene:analyzers-common',
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/IndexVersionCheck.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/IndexVersionCheck.java
deleted file mode 100644
index 6060312..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/IndexVersionCheck.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.gerrit.lucene.LuceneChangeIndex.LUCENE_VERSION;
-
-import static org.apache.lucene.util.Version.LUCENE_CURRENT;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.inject.Inject;
-import com.google.inject.ProvisionException;
-
-import org.apache.lucene.util.Version;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.Map;
-
-public class IndexVersionCheck implements LifecycleListener {
-  public static final Map<String, Integer> SCHEMA_VERSIONS = ImmutableMap.of(
-      LuceneChangeIndex.CHANGES_OPEN, ChangeField.SCHEMA_VERSION,
-      LuceneChangeIndex.CHANGES_CLOSED, ChangeField.SCHEMA_VERSION);
-
-  public static File gerritIndexConfig(SitePaths sitePaths) {
-    return new File(sitePaths.index_dir, "gerrit_index.config");
-  }
-
-  private final SitePaths sitePaths;
-
-  @Inject
-  IndexVersionCheck(SitePaths sitePaths) {
-    this.sitePaths = sitePaths;
-  }
-
-  @Override
-  public void start() {
-    File file = gerritIndexConfig(sitePaths);
-    try {
-      FileBasedConfig cfg = new FileBasedConfig(file, FS.detect());
-      cfg.load();
-      for (Map.Entry<String, Integer> e : SCHEMA_VERSIONS.entrySet()) {
-        int schemaVersion = cfg.getInt("index", e.getKey(), "schemaVersion", 0);
-        if (schemaVersion != e.getValue()) {
-          throw new ProvisionException(String.format(
-              "wrong index schema version for \"%s\": expected %d, found %d%s",
-              e.getKey(), e.getValue(), schemaVersion, upgrade()));
-        }
-      }
-      @SuppressWarnings("deprecation")
-      Version luceneVersion =
-          cfg.getEnum("lucene", null, "version", LUCENE_CURRENT);
-      if (luceneVersion != LUCENE_VERSION) {
-        throw new ProvisionException(String.format(
-            "wrong Lucene version: expected %d, found %d%s",
-            luceneVersion, LUCENE_VERSION, upgrade()));
-
-      }
-    } catch (IOException e) {
-      throw new ProvisionException("unable to read " + file);
-    } catch (ConfigInvalidException e) {
-      throw new ProvisionException("invalid config file " + file);
-    }
-  }
-
-  @Override
-  public void stop() {
-    // Do nothing.
-  }
-
-  private final String upgrade() {
-    return "\nRun reindex to rebuild the index:\n"
-      + "$ java -jar gerrit.war reindex -d "
-      + sitePaths.site_path.getAbsolutePath();
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 5e8cac5..2494e2c 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.lucene;
 
-import static com.google.gerrit.lucene.IndexVersionCheck.SCHEMA_VERSIONS;
-import static com.google.gerrit.lucene.IndexVersionCheck.gerritIndexConfig;
-import static com.google.gerrit.server.query.change.IndexRewriteImpl.CLOSED_STATUSES;
-import static com.google.gerrit.server.query.change.IndexRewriteImpl.OPEN_STATUSES;
+import static com.google.gerrit.server.index.IndexRewriteImpl.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.IndexRewriteImpl.OPEN_STATUSES;
 
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableSet;
@@ -26,21 +24,25 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
-import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.ChangeField;
 import com.google.gerrit.server.index.ChangeIndex;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexRewriteImpl;
+import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.IndexRewriteImpl;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
 import org.apache.lucene.document.Document;
@@ -65,7 +67,6 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -75,11 +76,12 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
+import javax.annotation.Nullable;
+
 /**
  * Secondary index implementation using Apache Lucene.
  * <p>
@@ -88,15 +90,19 @@
  * though there may be some lag between a committed write and it showing up to
  * other threads' searchers.
  */
-public class LuceneChangeIndex implements ChangeIndex, LifecycleListener {
+public class LuceneChangeIndex implements ChangeIndex {
   private static final Logger log =
       LoggerFactory.getLogger(LuceneChangeIndex.class);
 
   public static final Version LUCENE_VERSION = Version.LUCENE_43;
-  public static final String CHANGES_OPEN = "changes_open";
-  public static final String CHANGES_CLOSED = "changes_closed";
+  public static final String CHANGES_OPEN = "open";
+  public static final String CHANGES_CLOSED = "closed";
   private static final String ID_FIELD = ChangeField.LEGACY_ID.getName();
 
+  static interface Factory {
+    LuceneChangeIndex create(Schema<ChangeData> schema, String base);
+  }
+
   private static IndexWriterConfig getIndexWriterConfig(Config cfg, String name) {
     IndexWriterConfig writerConfig = new IndexWriterConfig(LUCENE_VERSION,
         new StandardAnalyzer(LUCENE_VERSION));
@@ -112,29 +118,37 @@
   private final SitePaths sitePaths;
   private final FillArgs fillArgs;
   private final ExecutorService executor;
-  private final boolean readOnly;
+  private final File dir;
+  private final Schema<ChangeData> schema;
   private final SubIndex openIndex;
   private final SubIndex closedIndex;
 
-  LuceneChangeIndex(Config cfg, SitePaths sitePaths,
-      ListeningScheduledExecutorService executor, FillArgs fillArgs,
-      boolean readOnly) throws IOException {
+  @AssistedInject
+  LuceneChangeIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      @IndexExecutor ListeningScheduledExecutorService executor,
+      FillArgs fillArgs,
+      @Assisted Schema<ChangeData> schema,
+      @Assisted @Nullable String base) throws IOException {
     this.sitePaths = sitePaths;
     this.fillArgs = fillArgs;
     this.executor = executor;
-    this.readOnly = readOnly;
-    openIndex = new SubIndex(new File(sitePaths.index_dir, CHANGES_OPEN),
+    this.schema = schema;
+
+    if (base == null) {
+      dir = LuceneVersionManager.getDir(sitePaths, schema);
+    } else {
+      dir = new File(base);
+    }
+    openIndex = new SubIndex(new File(dir, CHANGES_OPEN),
         getIndexWriterConfig(cfg, "changes_open"));
-    closedIndex = new SubIndex(new File(sitePaths.index_dir, CHANGES_CLOSED),
-          getIndexWriterConfig(cfg, "changes_closed"));
+    closedIndex = new SubIndex(new File(dir, CHANGES_CLOSED),
+        getIndexWriterConfig(cfg, "changes_closed"));
   }
 
   @Override
-  public void start() {
-  }
-
-  @Override
-  public void stop() {
+  public void close() {
     List<Future<?>> closeFutures = Lists.newArrayListWithCapacity(2);
     closeFutures.add(executor.submit(new Runnable() {
       @Override
@@ -153,15 +167,16 @@
     }
   }
 
+  @Override
+  public Schema<ChangeData> getSchema() {
+    return schema;
+  }
+
   @SuppressWarnings("unchecked")
   @Override
   public ListenableFuture<Void> insert(ChangeData cd) throws IOException {
     Term id = QueryBuilder.idTerm(cd);
     Document doc = toDocument(cd);
-    if (readOnly) {
-      return Futures.immediateFuture(null);
-    }
-
     if (cd.getChange().getStatus().isOpen()) {
       return allOf(
           closedIndex.delete(id),
@@ -178,9 +193,6 @@
   public ListenableFuture<Void> replace(ChangeData cd) throws IOException {
     Term id = QueryBuilder.idTerm(cd);
     Document doc = toDocument(cd);
-    if (readOnly) {
-      return Futures.immediateFuture(null);
-    }
     if (cd.getChange().getStatus().isOpen()) {
       return allOf(
           closedIndex.delete(id),
@@ -196,9 +208,6 @@
   @Override
   public ListenableFuture<Void> delete(ChangeData cd) throws IOException {
     Term id = QueryBuilder.idTerm(cd);
-    if (readOnly) {
-      return Futures.immediateFuture(null);
-    }
     return allOf(
         openIndex.delete(id),
         closedIndex.delete(id));
@@ -235,6 +244,17 @@
     return new QuerySource(indexes, QueryBuilder.toQuery(p));
   }
 
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    try {
+      FileBasedConfig cfg = LuceneVersionManager.loadGerritIndexConfig(sitePaths);
+      LuceneVersionManager.setReady(cfg, schema.getVersion(), ready);
+      cfg.save();
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
   private static class QuerySource implements ChangeDataSource {
     // TODO(dborowitz): Push limit down from predicate tree.
     private static final int LIMIT = 1000;
@@ -259,6 +279,11 @@
     }
 
     @Override
+    public String toString() {
+      return query.toString();
+    }
+
+    @Override
     public ResultSet<ChangeData> read() throws OrmException {
       IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
       Sort sort = new Sort(
@@ -318,7 +343,7 @@
   private Document toDocument(ChangeData cd) throws IOException {
     try {
       Document result = new Document();
-      for (FieldDef<ChangeData, ?> f : ChangeField.ALL.values()) {
+      for (FieldDef<ChangeData, ?> f : schema.getFields().values()) {
         if (f.isRepeatable()) {
           add(result, f, (Iterable<?>) f.get(cd, fillArgs));
         } else {
@@ -369,17 +394,4 @@
   private static Field.Store store(FieldDef<?, ?> f) {
     return f.isStored() ? Field.Store.YES : Field.Store.NO;
   }
-
-  @Override
-  public void finishIndex() throws IOException,
-      ConfigInvalidException {
-    FileBasedConfig cfg =
-        new FileBasedConfig(gerritIndexConfig(sitePaths), FS.detect());
-
-    for (Map.Entry<String, Integer> e : SCHEMA_VERSIONS.entrySet()) {
-      cfg.setInt("index", e.getKey(), "schemaVersion", e.getValue());
-    }
-    cfg.setEnum("lucene", null, "version", LUCENE_VERSION);
-    cfg.save();
-  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 3664d82..4e8f3f9 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -14,53 +14,102 @@
 
 package com.google.gerrit.lucene;
 
-import com.google.common.util.concurrent.ListeningScheduledExecutorService;
+import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.FactoryModule;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.ChangeSchemas;
+import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
 
-import org.eclipse.jgit.lib.Config;
-
-import java.io.IOException;
-
 public class LuceneIndexModule extends LifecycleModule {
-  private final boolean checkVersion;
+  private final Integer singleVersion;
   private final int threads;
-  private final boolean readOnly;
+  private final String base;
 
   public LuceneIndexModule() {
-    this(true, 0, false);
+    this(null, 0, null);
   }
 
-  public LuceneIndexModule(boolean checkVersion, int threads,
-      boolean readOnly) {
-    this.checkVersion = checkVersion;
+  public LuceneIndexModule(Integer singleVersion, int threads,
+      String base) {
+    this.singleVersion = singleVersion;
     this.threads = threads;
-    this.readOnly = readOnly;
+    this.base = base;
   }
 
   @Override
   protected void configure() {
+    install(new FactoryModule() {
+      @Override
+      public void configure() {
+        factory(LuceneChangeIndex.Factory.class);
+      }
+    });
     install(new IndexModule(threads));
-    bind(ChangeIndex.class).to(LuceneChangeIndex.class);
-    listener().to(LuceneChangeIndex.class);
-    if (checkVersion) {
-      listener().to(IndexVersionCheck.class);
+    if (singleVersion == null && base == null) {
+      install(new MultiVersionModule());
+    } else {
+      install(new SingleVersionModule());
     }
   }
 
-  @Provides
+  private class MultiVersionModule extends LifecycleModule {
+    @Override
+    public void configure() {
+      install(new FactoryModule() {
+        @Override
+        public void configure() {
+          factory(OnlineReindexer.Factory.class);
+        }
+      });
+      listener().to(LuceneVersionManager.class);
+    }
+  }
+
+  private class SingleVersionModule extends LifecycleModule {
+    @Override
+    public void configure() {
+      listener().to(SingleVersionListener.class);
+    }
+
+    @Provides
+    @Singleton
+    LuceneChangeIndex getIndex(LuceneChangeIndex.Factory factory,
+        SitePaths sitePaths) {
+      Schema<ChangeData> schema = singleVersion != null
+          ? ChangeSchemas.get(singleVersion)
+          : ChangeSchemas.getLatest();
+      return factory.create(schema, base);
+    }
+  }
+
   @Singleton
-  public LuceneChangeIndex getChangeIndex(@GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      @IndexExecutor ListeningScheduledExecutorService executor,
-      FillArgs fillArgs) throws IOException {
-    return new LuceneChangeIndex(cfg, sitePaths, executor, fillArgs, readOnly);
+  static class SingleVersionListener implements LifecycleListener {
+    private final IndexCollection indexes;
+    private final LuceneChangeIndex index;
+
+    @Inject
+    SingleVersionListener(IndexCollection indexes,
+        LuceneChangeIndex index) {
+      this.indexes = indexes;
+      this.index = index;
+    }
+
+    @Override
+    public void start() {
+      indexes.setSearchIndex(index);
+      indexes.addWriteIndex(index);
+    }
+
+    @Override
+    public void stop() {
+      index.close();
+    }
   }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
new file mode 100644
index 0000000..91dd015
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -0,0 +1,217 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.package com.google.gerrit.server.git;
+
+package com.google.gerrit.lucene;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.ChangeSchemas;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.TreeMap;
+
+@Singleton
+class LuceneVersionManager implements LifecycleListener {
+  private static final Logger log = LoggerFactory
+      .getLogger(LuceneVersionManager.class);
+
+  private static final String CHANGES_PREFIX = "changes_";
+
+  private static class Version {
+    private final Schema<ChangeData> schema;
+    private final int version;
+    private final boolean exists;
+    private final boolean ready;
+
+    private Version(Schema<ChangeData> schema, int version, boolean exists,
+        boolean ready) {
+      checkArgument(schema == null || schema.getVersion() == version);
+      this.schema = schema;
+      this.version = version;
+      this.exists = exists;
+      this.ready = ready;
+    }
+  }
+
+  static File getDir(SitePaths sitePaths, Schema<ChangeData> schema) {
+    return new File(sitePaths.index_dir, String.format("%s%04d",
+        CHANGES_PREFIX, schema.getVersion()));
+  }
+
+  static FileBasedConfig loadGerritIndexConfig(SitePaths sitePaths)
+      throws ConfigInvalidException, IOException {
+    FileBasedConfig cfg = new FileBasedConfig(
+        new File(sitePaths.index_dir, "gerrit_index.config"), FS.detect());
+    cfg.load();
+    return cfg;
+  }
+
+  static void setReady(Config cfg, int version, boolean ready) {
+    cfg.setBoolean("index", Integer.toString(version), "ready", ready);
+  }
+
+  private static boolean getReady(Config cfg, int version) {
+    return cfg.getBoolean("index", Integer.toString(version), "ready", false);
+  }
+
+  private final SitePaths sitePaths;
+  private final LuceneChangeIndex.Factory indexFactory;
+  private final IndexCollection indexes;
+  private final OnlineReindexer.Factory reindexerFactory;
+
+  @Inject
+  LuceneVersionManager(
+      SitePaths sitePaths,
+      LuceneChangeIndex.Factory indexFactory,
+      IndexCollection indexes,
+      OnlineReindexer.Factory reindexerFactory) {
+    this.sitePaths = sitePaths;
+    this.indexFactory = indexFactory;
+    this.indexes = indexes;
+    this.reindexerFactory = reindexerFactory;
+  }
+
+  @Override
+  public void start() {
+    FileBasedConfig cfg;
+    try {
+      cfg = loadGerritIndexConfig(sitePaths);
+    } catch (ConfigInvalidException e) {
+      throw fail(e);
+    } catch (IOException e) {
+      throw fail(e);
+    }
+
+    TreeMap<Integer, Version> versions = scanVersions(cfg);
+    // Search from the most recent ready version.
+    // Write to the most recent ready version and the most recent version.
+    Version search = null;
+    List<Version> write = Lists.newArrayListWithCapacity(2);
+    for (Version v : versions.descendingMap().values()) {
+      if (v.schema == null) {
+        continue;
+      }
+      if (write.isEmpty()) {
+        write.add(v);
+      }
+      if (v.ready) {
+        search = v;
+        if (!write.contains(v)) {
+          write.add(v);
+        }
+        break;
+      }
+    }
+    if (search == null) {
+      throw new ProvisionException("No index versions ready; run Reindex");
+    }
+
+    markNotReady(cfg, versions.values(), write);
+    LuceneChangeIndex searchIndex = indexFactory.create(search.schema, null);
+    indexes.setSearchIndex(searchIndex);
+    for (Version v : write) {
+      if (v.schema != null) {
+        if (v.version != search.version) {
+          indexes.addWriteIndex(indexFactory.create(v.schema, null));
+        } else {
+          indexes.addWriteIndex(searchIndex);
+        }
+      }
+    }
+
+    int latest = write.get(0).version;
+    if (latest != search.version) {
+      reindexerFactory.create(latest).start();
+    }
+  }
+
+  private TreeMap<Integer, Version> scanVersions(Config cfg) {
+    TreeMap<Integer, Version> versions = Maps.newTreeMap();
+    for (Schema<ChangeData> schema : ChangeSchemas.ALL.values()) {
+      File f = getDir(sitePaths, schema);
+      boolean exists = f.exists() && f.isDirectory();
+      if (exists && !f.isDirectory()) {
+        log.warn("Not a directory: %s", f.getAbsolutePath());
+      }
+      int v = schema.getVersion();
+      versions.put(v, new Version(schema, v, exists, getReady(cfg, v)));
+    }
+
+    for (File f : sitePaths.index_dir.listFiles()) {
+      if (!f.getName().startsWith(CHANGES_PREFIX)) {
+        continue;
+      }
+      String versionStr = f.getName().substring(CHANGES_PREFIX.length());
+      Integer v = Ints.tryParse(versionStr);
+      if (v == null || versionStr.length() != 4) {
+        log.warn("Unrecognized version in index directory: {}",
+            f.getAbsolutePath());
+        continue;
+      }
+      if (!versions.containsKey(v)) {
+        versions.put(v, new Version(null, v, true, getReady(cfg, v)));
+      }
+    }
+    return versions;
+  }
+
+  private void markNotReady(FileBasedConfig cfg, Iterable<Version> versions,
+      Collection<Version> inUse) {
+    boolean dirty = false;
+    for (Version v : versions) {
+      if (!inUse.contains(v) && v.exists) {
+        setReady(cfg, v.version, false);
+        dirty = true;
+      }
+    }
+    if (dirty) {
+      try {
+        cfg.save();
+      } catch (IOException e) {
+        throw fail(e);
+      }
+    }
+  }
+
+  private ProvisionException fail(Throwable t) {
+    ProvisionException e = new ProvisionException("Error scanning indexes");
+    e.initCause(t);
+    throw e;
+  }
+
+  @Override
+  public void stop() {
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/OnlineReindexer.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/OnlineReindexer.java
new file mode 100644
index 0000000..08338eb
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/OnlineReindexer.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.package com.google.gerrit.server.git;
+
+package com.google.gerrit.lucene;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.server.index.ChangeBatchIndexer;
+import com.google.gerrit.server.index.ChangeIndex;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+
+public class OnlineReindexer {
+  private static final Logger log = LoggerFactory
+      .getLogger(OnlineReindexer.class);
+
+  public interface Factory {
+    OnlineReindexer create(int version);
+  }
+
+  private final IndexCollection indexes;
+  private final ChangeBatchIndexer batchIndexer;
+  private final ProjectCache projectCache;
+  private final int version;
+
+  @Inject
+  OnlineReindexer(
+      IndexCollection indexes,
+      ChangeBatchIndexer batchIndexer,
+      ProjectCache projectCache,
+      @Assisted int version) {
+    this.indexes = indexes;
+    this.batchIndexer = batchIndexer;
+    this.projectCache = projectCache;
+    this.version = version;
+  }
+
+  public void start() {
+    Thread t = new Thread() {
+      @Override
+      public void run() {
+        reindex();
+      }
+    };
+    t.setName(String.format("Reindex v%d-v%d",
+        version(indexes.getSearchIndex()), version));
+    t.start();
+  }
+
+  private static int version(ChangeIndex i) {
+    return i.getSchema().getVersion();
+  }
+
+  private void reindex() {
+    ChangeIndex index = checkNotNull(indexes.getWriteIndex(version),
+        "not an active write schema version: %s", version);
+    log.info("Starting online reindex from schema version {} to {}",
+        version(indexes.getSearchIndex()), version(index));
+    ChangeBatchIndexer.Result result = batchIndexer.indexAll(
+        index, projectCache.all(), -1, -1, null, null);
+    if (!result.success()) {
+      log.error("Online reindex of schema version {} failed", version(index));
+      return;
+    }
+
+    indexes.setSearchIndex(index);
+    log.info("Reindex complete, using schema version {}", version(index));
+    try {
+      index.markReady(true);
+    } catch (IOException e) {
+      log.warn("Error activating new schema version {}", version(index));
+    }
+
+    List<ChangeIndex> toRemove = Lists.newArrayListWithExpectedSize(1);
+    for (ChangeIndex i : indexes.getWriteIndexes()) {
+      if (version(i) != version(index)) {
+        toRemove.add(i);
+      }
+    }
+    for (ChangeIndex i : toRemove) {
+      try {
+        i.markReady(false);
+        indexes.removeWriteIndex(version(i));
+      } catch (IOException e) {
+        log.warn("Error deactivating old schema version {}", version(i));
+      }
+    }
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
index e1c08d2..f491bc2 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -18,6 +18,7 @@
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
 import static org.apache.lucene.search.BooleanClause.Occur.SHOULD;
 
+import com.google.common.collect.Lists;
 import com.google.gerrit.server.index.ChangeField;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.IndexPredicate;
@@ -32,9 +33,9 @@
 import com.google.gerrit.server.query.change.SortKeyPredicate;
 
 import org.apache.lucene.index.Term;
-import org.apache.lucene.search.BooleanClause;
 import org.apache.lucene.search.BooleanQuery;
 import org.apache.lucene.search.FuzzyQuery;
+import org.apache.lucene.search.MatchAllDocsQuery;
 import org.apache.lucene.search.NumericRangeQuery;
 import org.apache.lucene.search.PrefixQuery;
 import org.apache.lucene.search.Query;
@@ -44,6 +45,7 @@
 import org.apache.lucene.util.NumericUtils;
 
 import java.sql.Timestamp;
+import java.util.List;
 
 public class QueryBuilder {
   private static final String ID_FIELD = ChangeField.LEGACY_ID.getName();
@@ -55,28 +57,66 @@
   public static Query toQuery(Predicate<ChangeData> p)
       throws QueryParseException {
     if (p instanceof AndPredicate) {
-      return booleanQuery(p, MUST);
+      return and(p);
     } else if (p instanceof OrPredicate) {
-      return booleanQuery(p, SHOULD);
+      return or(p);
     } else if (p instanceof NotPredicate) {
-      if (p.getChild(0) instanceof TimestampRangePredicate) {
-        return notTimestampQuery(
-            (TimestampRangePredicate<ChangeData>) p.getChild(0));
-      }
-      return booleanQuery(p, MUST_NOT);
+      return not(p);
     } else if (p instanceof IndexPredicate) {
       return fieldQuery((IndexPredicate<ChangeData>) p);
     } else {
-      throw new QueryParseException("Cannot convert to index predicate: " + p);
+      throw new QueryParseException("cannot create query for index: " + p);
     }
   }
 
-  private static Query booleanQuery(Predicate<ChangeData> p, BooleanClause.Occur o)
-      throws QueryParseException {
-    BooleanQuery q = new BooleanQuery();
-    for (int i = 0; i < p.getChildCount(); i++) {
-      q.add(toQuery(p.getChild(i)), o);
+  private static Query or(Predicate<ChangeData> p) throws QueryParseException {
+    try {
+      BooleanQuery q = new BooleanQuery();
+      for (int i = 0; i < p.getChildCount(); i++) {
+        q.add(toQuery(p.getChild(i)), SHOULD);
+      }
+      return q;
+    } catch (BooleanQuery.TooManyClauses e) {
+      throw new QueryParseException("cannot create query for index: " + p, e);
     }
+  }
+
+  private static Query and(Predicate<ChangeData> p) throws QueryParseException {
+    try {
+      BooleanQuery b = new BooleanQuery();
+      List<Query> not = Lists.newArrayListWithCapacity(p.getChildCount());
+      for (int i = 0; i < p.getChildCount(); i++) {
+        Predicate<ChangeData> c = p.getChild(i);
+        if (c instanceof NotPredicate) {
+          Predicate<ChangeData> n = c.getChild(0);
+          if (n instanceof TimestampRangePredicate) {
+            b.add(notTimestamp((TimestampRangePredicate<ChangeData>) n), MUST);
+          } else {
+            not.add(toQuery(n));
+          }
+        } else {
+          b.add(toQuery(c), MUST);
+        }
+      }
+      for (Query q : not) {
+        b.add(q, MUST_NOT);
+      }
+      return b;
+    } catch (BooleanQuery.TooManyClauses e) {
+      throw new QueryParseException("cannot create query for index: " + p, e);
+    }
+  }
+
+  private static Query not(Predicate<ChangeData> p) throws QueryParseException {
+    Predicate<ChangeData> n = p.getChild(0);
+    if (n instanceof TimestampRangePredicate) {
+      return notTimestamp((TimestampRangePredicate<ChangeData>) n);
+    }
+
+    // Lucene does not support negation, start with all and subtract.
+    BooleanQuery q = new BooleanQuery();
+    q.add(new MatchAllDocsQuery(), MUST);
+    q.add(toQuery(n), MUST_NOT);
     return q;
   }
 
@@ -121,8 +161,8 @@
   private static Query sortKeyQuery(SortKeyPredicate p) {
     return NumericRangeQuery.newLongRange(
         p.getField().getName(),
-        p.getMinValue(),
-        p.getMaxValue(),
+        p.getMinValue() != Long.MIN_VALUE ? p.getMinValue() : null,
+        p.getMaxValue() != Long.MAX_VALUE ? p.getMaxValue() : null,
         true, true);
   }
 
@@ -140,7 +180,7 @@
     throw new QueryParseException("not a timestamp: " + p);
   }
 
-  private static Query notTimestampQuery(TimestampRangePredicate<ChangeData> r)
+  private static Query notTimestamp(TimestampRangePredicate<ChangeData> r)
       throws QueryParseException {
     if (r.getMinTimestamp().getTime() == 0) {
       return NumericRangeQuery.newIntRange(
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index 3c5b0ab..f740fba 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -16,17 +16,8 @@
 
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.AsyncFunction;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningScheduledExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -38,18 +29,14 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.index.ChangeBatchIndexer;
 import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.ChangeIndexer;
-import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.ChangeSchemas;
+import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.NoIndexModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
-import com.google.gerrit.server.patch.PatchListLoader;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.solr.SolrIndexModule;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -61,51 +48,36 @@
 import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache;
 import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
+import org.eclipse.jgit.util.io.NullOutputStream;
 import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
 import java.util.Collections;
-import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
 
 public class Reindex extends SiteProgram {
-  private static final Logger log = LoggerFactory.getLogger(Reindex.class);
-
   @Option(name = "--threads", usage = "Number of threads to use for indexing")
   private int threads = Runtime.getRuntime().availableProcessors();
 
-  @Option(name = "--dry-run", usage = "Dry run: don't write anything to index")
-  private boolean dryRun;
+  @Option(name = "--schema-version",
+      usage = "Schema version to reindex; default is most recent version")
+  private Integer version;
+
+  @Option(name = "--output", usage = "Prefix for output; path for local disk index, or prefix for remote index")
+  private String outputBase;
 
   @Option(name = "--verbose", usage = "Output debug information for each change")
   private boolean verbose;
 
+  @Option(name = "--dry-run", usage = "Dry run: don't write anything to index")
+  private boolean dryRun;
+
   private Injector dbInjector;
   private Injector sysInjector;
+  private ChangeIndex index;
 
   @Override
   public int run() throws Exception {
@@ -114,22 +86,23 @@
     if (IndexModule.getIndexType(dbInjector) == IndexType.SQL) {
       throw die("index.type must be configured (or not SQL)");
     }
-
+    if (version == null) {
+      version = ChangeSchemas.getLatest().getVersion();
+    }
     LifecycleManager dbManager = new LifecycleManager();
     dbManager.add(dbInjector);
     dbManager.start();
 
     sysInjector = createSysInjector();
-
-    // Delete before any index may be created depending on this data.
-    deleteAll();
-
     LifecycleManager sysManager = new LifecycleManager();
     sysManager.add(sysInjector);
     sysManager.start();
 
+    index = sysInjector.getInstance(IndexCollection.class).getSearchIndex();
+    index.markReady(false);
+    index.deleteAll();
     int result = indexAll();
-    writeVersion();
+    index.markReady(true);
 
     sysManager.stop();
     dbManager.stop();
@@ -142,10 +115,10 @@
     AbstractModule changeIndexModule;
     switch (IndexModule.getIndexType(dbInjector)) {
       case LUCENE:
-        changeIndexModule = new LuceneIndexModule(false, threads, dryRun);
+        changeIndexModule = new LuceneIndexModule(version, threads, outputBase);
         break;
       case SOLR:
-        changeIndexModule = new SolrIndexModule(false, threads);
+        changeIndexModule = new SolrIndexModule(false, threads, outputBase);
         break;
       default:
         changeIndexModule = new NoIndexModule();
@@ -207,19 +180,8 @@
     }
   }
 
-  private void deleteAll() throws IOException {
-    if (dryRun) {
-      return;
-    }
-    ChangeIndex index = sysInjector.getInstance(ChangeIndex.class);
-    index.deleteAll();
-  }
-
   private int indexAll() throws Exception {
     ReviewDb db = sysInjector.getInstance(ReviewDb.class);
-    ListeningScheduledExecutorService executor = sysInjector.getInstance(
-        Key.get(ListeningScheduledExecutorService.class, IndexExecutor.class));
-
     ProgressMonitor pm = new TextProgressMonitor();
     pm.start(1);
     pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
@@ -237,243 +199,14 @@
     }
     pm.endTask();
 
-    final MultiProgressMonitor mpm =
-        new MultiProgressMonitor(System.err, "Reindexing changes");
-    final Task projTask = mpm.beginSubTask("projects", projects.size());
-    final Task doneTask = mpm.beginSubTask(null, changeCount);
-    final Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
-
-    Stopwatch sw = new Stopwatch().start();
-    final List<ListenableFuture<?>> futures =
-        Lists.newArrayListWithCapacity(projects.size());
-    final AtomicBoolean ok = new AtomicBoolean(true);
-
-    for (final Project.NameKey project : projects) {
-      final ListenableFuture<?> future = executor.submit(
-          new ReindexProject(project, doneTask, failedTask));
-      futures.add(future);
-      future.addListener(new Runnable() {
-        @Override
-        public void run() {
-          try {
-            future.get();
-          } catch (InterruptedException e) {
-            fail(project, e);
-          } catch (ExecutionException e) {
-            ok.set(false); // Logged by indexer.
-          } catch (RuntimeException e) {
-            failAndThrow(project, e);
-          } catch (Error e) {
-            failAndThrow(project, e);
-          } finally {
-            projTask.update(1);
-          }
-        }
-
-        private void fail(Project.NameKey project, Throwable t) {
-          log.error("Failed to index project " + project, t);
-          ok.set(false);
-        }
-
-        private void failAndThrow(Project.NameKey project, RuntimeException e) {
-          fail(project, e);
-          throw e;
-        }
-
-        private void failAndThrow(Project.NameKey project, Error e) {
-          fail(project, e);
-          throw e;
-        }
-      }, MoreExecutors.sameThreadExecutor());
-    }
-
-    mpm.waitFor(Futures.transform(Futures.successfulAsList(futures),
-        new AsyncFunction<List<?>, Void>() {
-          @Override
-          public ListenableFuture<Void> apply(List<?> input) throws Exception {
-            mpm.end();
-            return Futures.immediateFuture(null);
-          }
-    }));
-    double elapsed = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-    int n = doneTask.getCount() + failedTask.getCount();
-    System.out.format("Reindexed %d changes in %.01fs (%.01f/s)\n",
-        n, elapsed, n/elapsed);
-
-    return ok.get() ? 0 : 1;
-  }
-
-  private class ReindexProject implements Callable<Void> {
-    private final ChangeIndexer indexer;
-    private final Project.NameKey project;
-    private final ListMultimap<ObjectId, ChangeData> byId;
-    private final Task done;
-    private final Task failed;
-    private Repository repo;
-    private RevWalk walk;
-
-    private ReindexProject(Project.NameKey project, Task done, Task failed) {
-      this.indexer = sysInjector.getInstance(ChangeIndexer.class);
-      this.project = project;
-      this.byId = ArrayListMultimap.create();
-      this.done = done;
-      this.failed = failed;
-    }
-
-    @Override
-    public Void call() throws Exception {
-      ReviewDb db = sysInjector.getInstance(ReviewDb.class);
-      GitRepositoryManager mgr = sysInjector.getInstance(GitRepositoryManager.class);
-      repo = mgr.openRepository(project);
-
-      try {
-        Map<String, Ref> refs = repo.getAllRefs();
-        for (Change c : db.changes().byProject(project)) {
-          String refName = c.currentPatchSetId().toRefName();
-          Ref r = refs.get(refName);
-          if (r != null) {
-            byId.put(r.getObjectId(), new ChangeData(c));
-          } else {
-            fail("Failed to index change " + c.getId()
-                + " (" + refName + " not found)", true, null);
-          }
-        }
-        walk();
-      } finally {
-        repo.close();
-        RepositoryCache.close(repo); // Only used once per Reindex call.
-      }
-      return null;
-    }
-
-    private void walk() throws Exception {
-      walk = new RevWalk(repo);
-      try {
-        // Walk only refs first to cover as many changes as we can without having
-        // to mark every single change.
-        for (Ref ref : repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
-          RevObject o = walk.parseAny(ref.getObjectId());
-          if (o instanceof RevCommit) {
-            walk.markStart((RevCommit) o);
-          }
-        }
-
-        RevCommit bCommit;
-        while ((bCommit = walk.next()) != null && !byId.isEmpty()) {
-          if (byId.containsKey(bCommit)) {
-            getPathsAndIndex(bCommit);
-            byId.removeAll(bCommit);
-          }
-        }
-
-        for (ObjectId id : byId.keySet()) {
-          getPathsAndIndex(walk.parseCommit(id));
-        }
-      } finally {
-        walk.release();
-      }
-    }
-
-    private void getPathsAndIndex(RevCommit bCommit) throws Exception {
-      RevTree bTree = bCommit.getTree();
-      List<ChangeData> cds = Lists.newArrayList(byId.get(bCommit));
-      try {
-        RevTree aTree = aFor(bCommit, walk);
-        DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE);
-        try {
-          df.setRepository(repo);
-          if (!cds.isEmpty()) {
-            List<String> paths = (aTree != null)
-                ? getPaths(df.scan(aTree, bTree))
-                : Collections.<String>emptyList();
-            Iterator<ChangeData> cdit = cds.iterator();
-            for (ChangeData cd ; cdit.hasNext(); cdit.remove()) {
-              cd = cdit.next();
-              try {
-                cd.setCurrentFilePaths(paths);
-                indexer.indexTask(cd).call();
-                done.update(1);
-                if (verbose) {
-                  System.out.println("Reindexed change " + cd.getId());
-                }
-              } catch (Exception e) {
-                fail("Failed to index change " + cd.getId(), true, e);
-              }
-            }
-          }
-        } finally {
-          df.release();
-        }
-      } catch (Exception e) {
-        fail("Failed to index commit " + bCommit.name(), false, e);
-        for (ChangeData cd : cds) {
-          fail("Failed to index change " + cd.getId(), true, null);
-        }
-      }
-    }
-
-    private List<String> getPaths(List<DiffEntry> filenames) {
-      Set<String> paths = Sets.newTreeSet();
-      for (DiffEntry e : filenames) {
-        if (e.getOldPath() != null) {
-          paths.add(e.getOldPath());
-        }
-        if (e.getNewPath() != null) {
-          paths.add(e.getNewPath());
-        }
-      }
-      return ImmutableList.copyOf(paths);
-    }
-
-    private RevTree aFor(RevCommit b, RevWalk walk) throws IOException {
-      switch (b.getParentCount()) {
-        case 0:
-          return walk.parseTree(emptyTree());
-        case 1:
-          RevCommit a = b.getParent(0);
-          walk.parseBody(a);
-          return walk.parseTree(a.getTree());
-        case 2:
-          return PatchListLoader.automerge(repo, walk, b);
-        default:
-          return null;
-      }
-    }
-
-    private ObjectId emptyTree() throws IOException {
-      ObjectInserter oi = repo.newObjectInserter();
-      try {
-        ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
-        oi.flush();
-        return id;
-      } finally {
-        oi.release();
-      }
-    }
-
-    private void fail(String error, boolean failed, Exception e) {
-      if (failed) {
-        this.failed.update(1);
-      }
-
-      if (e != null) {
-        log.warn(error, e);
-      } else {
-        log.warn(error);
-      }
-
-      if (verbose) {
-        System.out.println(error);
-      }
-    }
-  }
-
-  private void writeVersion() throws IOException,
-      ConfigInvalidException {
-    if (dryRun) {
-      return;
-    }
-    ChangeIndex index = sysInjector.getInstance(ChangeIndex.class);
-    index.finishIndex();
+    ChangeBatchIndexer batchIndexer =
+        sysInjector.getInstance(ChangeBatchIndexer.class);
+    ChangeBatchIndexer.Result result = batchIndexer.indexAll(
+      index, projects, projects.size(), changeCount, System.err,
+      verbose ? System.out : NullOutputStream.INSTANCE);
+    int n = result.doneCount() + result.failedCount();
+    double t = result.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+    System.out.format("Reindexed %d changes in %.01fs (%.01f/s)\n", n, t, n/t);
+    return result.success() ? 0 : 1;
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index bf0af7f..415c55a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -91,6 +91,7 @@
     extractMailExample("Comment.vm");
     extractMailExample("CommentFooter.vm");
     extractMailExample("CommitMessageEdited.vm");
+    extractMailExample("Footer.vm");
     extractMailExample("Merged.vm");
     extractMailExample("MergeFail.vm");
     extractMailExample("NewChange.vm");
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/ClientSideFormatter.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/ClientSideFormatter.java
index 9f76a47..8e7c699 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/ClientSideFormatter.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/ClientSideFormatter.java
@@ -60,13 +60,12 @@
 
   @Override
   protected String prettify(String html, String type) {
-    return go(prettify.getContext(), html, type, diffPrefs.getTabSize());
+    return go(prettify.getContext(), html, type);
   }
 
   private static native String go(JavaScriptObject ctx, String srcText,
-      String srcType, int tabSize)
+      String srcType)
   /*-{
-     ctx.PR_TAB_WIDTH = tabSize;
      return ctx.prettyPrintOne(srcText, srcType);
   }-*/;
 }
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFormatter.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFormatter.java
index 2836f33..a84af5e 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFormatter.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFormatter.java
@@ -129,6 +129,7 @@
 
     String html = toHTML(src);
 
+    html = expandTabs(html);
     if (diffPrefs.isSyntaxHighlighting() && getFileType() != null
         && src.isWholeFile()) {
       // The prettify parsers don't like &#39; as an entity for the
@@ -146,11 +147,9 @@
       // Drop any '\r' to avoid this problem.
       html = html.replace("\r</span>\n", "</span>\n");
 
-      html = html.replace("\n", " \n");
+      html = html.replaceAll("(\r)?\n", " $1\n");
       html = prettify(html, getFileType());
-      html = html.replace(" \n", "\n");
-    } else {
-      html = expandTabs(html);
+      html = html.replaceAll(" (\r)?\n", "$1\n");
     }
 
     int pos = 0;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
index 7e0b90c..703edbb 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
@@ -30,10 +30,10 @@
   @Query("WHERE id.changeId = ? ORDER BY id.patchSetId")
   ResultSet<PatchSet> byChange(Change.Id id) throws OrmException;
 
-  @Query("WHERE revision = ? LIMIT 2")
+  @Query("WHERE revision = ?")
   ResultSet<PatchSet> byRevision(RevId rev) throws OrmException;
 
-  @Query("WHERE revision >= ? AND revision <= ? LIMIT 2")
+  @Query("WHERE revision >= ? AND revision <= ?")
   ResultSet<PatchSet> byRevisionRange(RevId reva, RevId revb)
       throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
index 1c66555..a96e713 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -24,6 +25,8 @@
 
 import org.kohsuke.args4j.Option;
 
+import java.util.concurrent.TimeUnit;
+
 class GetAvatar implements RestReadView<AccountResource> {
   private final DynamicItem<AvatarProvider> avatarProvider;
 
@@ -41,12 +44,14 @@
       throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
-      throw new ResourceNotFoundException();
+      throw (new ResourceNotFoundException())
+          .caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
     }
 
     String url = impl.getUrl(rsrc.getUser(), size);
     if (Strings.isNullOrEmpty(url)) {
-      throw new ResourceNotFoundException();
+      throw (new ResourceNotFoundException())
+          .caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
     } else {
       return Response.redirect(url);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
index 2a8e7c9..2e01f26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
@@ -127,10 +127,15 @@
   /** Can this user see this group exists? */
   public boolean isVisible() {
     AccountGroup accountGroup = GroupDescriptions.toAccountGroup(group);
+    /* Check for canAdministrateServer may seem redundant, but allows
+     * for visibility of all groups that are not an internal group to
+     * server administrators.
+     */
     return (accountGroup != null && accountGroup.isVisibleToAll())
       || user instanceof InternalUser
       || user.getEffectiveGroups().contains(group.getGroupUUID())
-      || isOwner();
+      || isOwner()
+      || user.getCapabilities().canAdministrateServer();
   }
 
   public boolean isOwner() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index 54f4f29..89ec948 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -16,9 +16,11 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.CacheControl;
 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.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -29,6 +31,8 @@
 
 import org.kohsuke.args4j.Option;
 
+import java.util.concurrent.TimeUnit;
+
 class Files implements ChildCollection<RevisionResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
   private final FileInfoJson fileInfoJson;
@@ -73,8 +77,11 @@
             resource.getChangeResource(), IdString.fromDecoded(base));
         basePatchSet = baseResource.getPatchSet();
       }
-      return fileInfoJson.toFileInfoMap(
-          resource.getChange(), resource.getPatchSet(), basePatchSet);
+      return Response.ok(fileInfoJson.toFileInfoMap(
+          resource.getChange(),
+          resource.getPatchSet(),
+          basePatchSet))
+        .caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
index b3cd813..3d9c0fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
@@ -19,9 +19,21 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
+import org.kohsuke.args4j.Option;
+
 public class GetDetail implements RestReadView<ChangeResource> {
   private final ChangeJson json;
 
+  @Option(name = "-o", multiValued = true, usage = "Output options")
+  void addOption(ListChangesOption o) {
+    json.addOption(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    json.addOptions(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
+  }
+
   @Inject
   GetDetail(ChangeJson json) {
     this.json = json
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
index 679d32b..f01c961 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
 import com.google.gerrit.common.data.PatchScript.FileMode;
+import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -49,6 +50,7 @@
 import org.kohsuke.args4j.spi.Setter;
 
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 public class GetDiff implements RestReadView<FileResource> {
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
@@ -150,7 +152,7 @@
       result.diffHeader = ps.getPatchHeader();
     }
     result.content = content.lines;
-    return Response.ok(result).caching(Response.CacheControl.PRIVATE);
+    return Response.ok(result).caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
   }
 
   static class Result {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 8317859..9f410b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -16,10 +16,14 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.ChangeHooks;
+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.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -31,6 +35,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.mail.ReplacePatchSetSender;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.RefControl;
@@ -48,13 +53,19 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 
 public class PatchSetInserter {
+  private static final Logger log =
+      LoggerFactory.getLogger(PatchSetInserter.class);
+
   public static interface Factory {
     PatchSetInserter create(Repository git, RevWalk revWalk, RefControl refControl,
         Change change, RevCommit commit);
@@ -69,6 +80,7 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final ChangeIndexer indexer;
   private boolean validateForReceiveCommits;
+  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
 
   private final Repository git;
   private final RevWalk revWalk;
@@ -90,6 +102,7 @@
       GitReferenceUpdated gitRefUpdated,
       CommitValidators.Factory commitValidatorsFactory,
       ChangeIndexer indexer,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
       @Assisted Repository git,
       @Assisted RevWalk revWalk,
       @Assisted RefControl refControl,
@@ -103,6 +116,7 @@
     this.gitRefUpdated = gitRefUpdated;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.indexer = indexer;
+    this.replacePatchSetFactory = replacePatchSetFactory;
 
     this.git = git;
     this.revWalk = revWalk;
@@ -178,6 +192,18 @@
       ChangeUtil.insertAncestors(db, patchSet.getId(), commit);
       db.patchSets().insert(Collections.singleton(patchSet));
 
+      final List<PatchSetApproval> oldPatchSetApprovals =
+          db.patchSetApprovals().byChange(change.getId()).toList();
+      final Set<Account.Id> oldReviewers = Sets.newHashSet();
+      final Set<Account.Id> oldCC = Sets.newHashSet();
+      for (PatchSetApproval a : oldPatchSetApprovals) {
+        if (a.getValue() != 0) {
+          oldReviewers.add(a.getAccountId());
+        } else {
+          oldCC.add(a.getAccountId());
+        }
+      }
+
       updatedChange =
           db.changes().atomicUpdate(change.getId(), new AtomicUpdate<Change>() {
             @Override
@@ -216,6 +242,21 @@
         db.changeMessages().insert(Collections.singleton(changeMessage));
       }
 
+      try {
+        PatchSetInfo info = patchSetInfoFactory.get(commit, patchSet.getId());
+        ReplacePatchSetSender cm =
+            replacePatchSetFactory.create(updatedChange);
+        cm.setFrom(user.getAccountId());
+        cm.setPatchSet(patchSet, info);
+        cm.setChangeMessage(changeMessage);
+        cm.addReviewers(oldReviewers);
+        cm.addExtraCC(oldCC);
+        cm.send();
+      } catch (Exception err) {
+        log.error("Cannot send email for new patch set on change " + updatedChange.getId(),
+            err);
+      }
+
       indexer.index(updatedChange);
       hooks.doPatchsetCreatedHook(updatedChange, patchSet, db);
     } finally {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 7b242d9..2e69ff0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -106,7 +106,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeQueryRewriter;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.util.IdGenerator;
@@ -172,7 +171,6 @@
     install(ThreadLocalRequestContext.module());
 
     bind(AccountResolver.class);
-    bind(ChangeQueryRewriter.class);
 
     factory(AccountInfoCacheFactory.Factory.class);
     factory(AddReviewerSender.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetVersion.java
new file mode 100644
index 0000000..f618959c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetVersion.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+
+public class GetVersion implements RestReadView<ConfigResource> {
+  @Override
+  public String apply(ConfigResource resource) throws ResourceNotFoundException {
+    String version = Version.getVersion();
+    if (version == null) {
+      throw new ResourceNotFoundException();
+    }
+    return version;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
index 81c2de9..0ea7390 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
@@ -26,5 +26,6 @@
     DynamicMap.mapOf(binder(), CONFIG_KIND);
     DynamicMap.mapOf(binder(), CAPABILITY_KIND);
     child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
+    get(CONFIG_KIND, "version").to(GetVersion.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
index 79d82e3..91df974 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
@@ -24,6 +24,7 @@
   public AccountAttribute uploader;
   public Long createdOn;
   public AccountAttribute author;
+  public boolean isDraft;
 
   public List<ApprovalAttribute> approvals;
   public List<PatchSetCommentAttribute> comments;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index bbef3ad..084b79b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -361,6 +361,7 @@
     p.ref = patchSet.getRefName();
     p.uploader = asAccountAttribute(patchSet.getUploader());
     p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
+    p.isDraft = patchSet.isDraft();
     final PatchSet.Id pId = patchSet.getId();
     try {
       final ReviewDb db = schema.open();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 5a18023..8bd8c0f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -729,7 +729,7 @@
     set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), defaultSubmitAction);
     set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), Project.InheritableBoolean.INHERIT);
 
-    set(rc, PROJECT, null, KEY_STATE, p.getState(), null);
+    set(rc, PROJECT, null, KEY_STATE, p.getState(), defaultStateValue);
 
     set(rc, DASHBOARD, null, KEY_DEFAULT, p.getDefaultDashboard());
     set(rc, DASHBOARD, null, KEY_LOCAL_DEFAULT, p.getLocalDefaultDashboard());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 487b177..2c8663e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromApprovals;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
-
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
@@ -62,6 +61,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeResource;
@@ -262,6 +262,7 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final TrackingFooters trackingFooters;
   private final TagCache tagCache;
+  private final AccountCache accountCache;
   private final ChangeInserter.Factory changeInserterFactory;
   private final WorkQueue workQueue;
   private final ListeningExecutorService changeUpdateExector;
@@ -318,6 +319,7 @@
       final ProjectCache projectCache,
       final GitRepositoryManager repoManager,
       final TagCache tagCache,
+      final AccountCache accountCache,
       final ChangeCache changeCache,
       final ChangeInserter.Factory changeInserterFactory,
       final CommitValidators.Factory commitValidatorsFactory,
@@ -353,6 +355,7 @@
     this.canonicalWebUrl = canonicalWebUrl;
     this.trackingFooters = trackingFooters;
     this.tagCache = tagCache;
+    this.accountCache = accountCache;
     this.changeInserterFactory = changeInserterFactory;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.workQueue = workQueue;
@@ -2055,6 +2058,7 @@
       return;
     }
 
+    boolean defaultName = Strings.isNullOrEmpty(currentUser.getAccount().getFullName());
     final RevWalk walk = rp.getRevWalk();
     walk.reset();
     walk.sort(RevSort.NONE);
@@ -2070,6 +2074,23 @@
         } else if (!validCommit(ctl, cmd, c)) {
           break;
         }
+
+        if (defaultName && currentUser.getEmailAddresses().contains(
+              c.getCommitterIdent().getEmailAddress())) {
+          try {
+            Account a = db.accounts().get(currentUser.getAccountId());
+            if (a != null && Strings.isNullOrEmpty(a.getFullName())) {
+              a.setFullName(c.getCommitterIdent().getName());
+              db.accounts().update(Collections.singleton(a));
+              currentUser.getAccount().setFullName(a.getFullName());
+              accountCache.evict(a.getId());
+            }
+          } catch (OrmException e) {
+            log.warn("Cannot default full_name", e);
+          } finally {
+            defaultName = false;
+          }
+        }
       }
     } catch (IOException err) {
       cmd.setResult(REJECTED_MISSING_OBJECT);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeBatchIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeBatchIndexer.java
new file mode 100644
index 0000000..261a439
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeBatchIndexer.java
@@ -0,0 +1,362 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.package com.google.gerrit.server.git;
+
+package com.google.gerrit.server.index;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningScheduledExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.patch.PatchListLoader;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+import org.eclipse.jgit.util.io.NullOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class ChangeBatchIndexer {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeBatchIndexer.class);
+
+  public static class Result {
+    private final long elapsedNanos;
+    private final boolean success;
+    private final int done;
+    private final int failed;
+
+    private Result(Stopwatch sw, boolean success, int done, int failed) {
+      this.elapsedNanos = sw.elapsed(TimeUnit.NANOSECONDS);
+      this.success = success;
+      this.done = done;
+      this.failed = failed;
+    }
+
+    public boolean success() {
+      return success;
+    }
+
+    public int doneCount() {
+      return done;
+    }
+
+    public int failedCount() {
+      return failed;
+    }
+
+    public long elapsed(TimeUnit timeUnit) {
+      return timeUnit.convert(elapsedNanos, TimeUnit.NANOSECONDS);
+    }
+  }
+
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final GitRepositoryManager repoManager;
+  private final ListeningScheduledExecutorService executor;
+  private final ChangeIndexer.Factory indexerFactory;
+
+  @Inject
+  ChangeBatchIndexer(SchemaFactory<ReviewDb> schemaFactory,
+      GitRepositoryManager repoManager,
+      @IndexExecutor ListeningScheduledExecutorService executor,
+      ChangeIndexer.Factory indexerFactory) {
+    this.schemaFactory = schemaFactory;
+    this.repoManager = repoManager;
+    this.executor = executor;
+    this.indexerFactory = indexerFactory;
+  }
+
+  public Result indexAll(ChangeIndex index, Iterable<Project.NameKey> projects,
+      int numProjects, int numChanges, OutputStream progressOut,
+      OutputStream verboseOut) {
+    if (progressOut == null) {
+      progressOut = NullOutputStream.INSTANCE;
+    }
+    PrintWriter verboseWriter = verboseOut != null ? new PrintWriter(verboseOut)
+        : null;
+
+    Stopwatch sw = new Stopwatch().start();
+    final MultiProgressMonitor mpm =
+        new MultiProgressMonitor(progressOut, "Reindexing changes");
+    final Task projTask = mpm.beginSubTask("projects",
+        numProjects >= 0 ? numProjects : MultiProgressMonitor.UNKNOWN);
+    final Task doneTask = mpm.beginSubTask(null,
+        numChanges >= 0 ? numChanges : MultiProgressMonitor.UNKNOWN);
+    final Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+
+    final List<ListenableFuture<?>> futures = Lists.newArrayList();
+    final AtomicBoolean ok = new AtomicBoolean(true);
+
+    for (final Project.NameKey project : projects) {
+      final ListenableFuture<?> future = executor.submit(new ReindexProject(
+          indexerFactory.create(index), project, doneTask, failedTask,
+          verboseWriter));
+      futures.add(future);
+      future.addListener(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            future.get();
+          } catch (InterruptedException e) {
+            fail(project, e);
+          } catch (ExecutionException e) {
+            fail(project, e);
+          } catch (RuntimeException e) {
+            failAndThrow(project, e);
+          } catch (Error e) {
+            failAndThrow(project, e);
+          } finally {
+            projTask.update(1);
+          }
+        }
+
+        private void fail(Project.NameKey project, Throwable t) {
+          log.error("Failed to index project " + project, t);
+          ok.set(false);
+        }
+
+        private void failAndThrow(Project.NameKey project, RuntimeException e) {
+          fail(project, e);
+          throw e;
+        }
+
+        private void failAndThrow(Project.NameKey project, Error e) {
+          fail(project, e);
+          throw e;
+        }
+      }, MoreExecutors.sameThreadExecutor());
+    }
+
+    try {
+      mpm.waitFor(Futures.transform(Futures.successfulAsList(futures),
+          new AsyncFunction<List<?>, Void>() {
+            @Override
+            public ListenableFuture<Void> apply(List<?> input) {
+              mpm.end();
+              return Futures.immediateFuture(null);
+            }
+      }));
+    } catch (ExecutionException e) {
+      log.error("Error in batch indexer", e);
+      ok.set(false);
+    }
+    return new Result(sw, ok.get(), doneTask.getCount(), failedTask.getCount());
+  }
+
+  private class ReindexProject implements Callable<Void> {
+    private final ChangeIndexer indexer;
+    private final Project.NameKey project;
+    private final ListMultimap<ObjectId, ChangeData> byId;
+    private final Task done;
+    private final Task failed;
+    private final PrintWriter verboseWriter;
+    private Repository repo;
+    private RevWalk walk;
+
+    private ReindexProject(ChangeIndexer indexer, Project.NameKey project,
+        Task done, Task failed, PrintWriter verboseWriter) {
+      this.indexer = indexer;
+      this.project = project;
+      this.byId = ArrayListMultimap.create();
+      this.done = done;
+      this.verboseWriter = verboseWriter;
+      this.failed = failed;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      ReviewDb db = schemaFactory.open();
+      try {
+        repo = repoManager.openRepository(project);
+        try {
+          Map<String, Ref> refs = repo.getAllRefs();
+          for (Change c : db.changes().byProject(project)) {
+            Ref r = refs.get(c.currentPatchSetId().toRefName());
+            if (r != null) {
+              byId.put(r.getObjectId(), new ChangeData(c));
+            }
+          }
+          walk();
+        } finally {
+          repo.close();
+        // TODO(dborowitz): Opening all repositories in a live server may be
+        // wasteful; see if we can determine which ones it is safe to close with
+        // RepositoryCache.close(repo).
+        }
+      } finally {
+        db.close();
+      }
+      return null;
+    }
+
+    private void walk() throws Exception {
+      walk = new RevWalk(repo);
+      try {
+        // Walk only refs first to cover as many changes as we can without having
+        // to mark every single change.
+        for (Ref ref : repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
+          RevObject o = walk.parseAny(ref.getObjectId());
+          if (o instanceof RevCommit) {
+            walk.markStart((RevCommit) o);
+          }
+        }
+
+        RevCommit bCommit;
+        while ((bCommit = walk.next()) != null && !byId.isEmpty()) {
+          if (byId.containsKey(bCommit)) {
+            getPathsAndIndex(bCommit);
+            byId.removeAll(bCommit);
+          }
+        }
+
+        for (ObjectId id : byId.keySet()) {
+          getPathsAndIndex(walk.parseCommit(id));
+        }
+      } finally {
+        walk.release();
+      }
+    }
+
+    private void getPathsAndIndex(RevCommit bCommit) throws Exception {
+      RevTree bTree = bCommit.getTree();
+      List<ChangeData> cds = Lists.newArrayList(byId.get(bCommit));
+      try {
+        RevTree aTree = aFor(bCommit, walk);
+        DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE);
+        try {
+          df.setRepository(repo);
+          if (!cds.isEmpty()) {
+            List<String> paths = (aTree != null)
+                ? getPaths(df.scan(aTree, bTree))
+                : Collections.<String>emptyList();
+            Iterator<ChangeData> cdit = cds.iterator();
+            for (ChangeData cd ; cdit.hasNext(); cdit.remove()) {
+              cd = cdit.next();
+              try {
+                cd.setCurrentFilePaths(paths);
+                indexer.indexTask(cd).call();
+                done.update(1);
+                if (verboseWriter != null) {
+                  verboseWriter.println("Reindexed change " + cd.getId());
+                }
+              } catch (Exception e) {
+                fail("Failed to index change " + cd.getId(), true, e);
+              }
+            }
+          }
+        } finally {
+          df.release();
+        }
+      } catch (Exception e) {
+        fail("Failed to index commit " + bCommit.name(), false, e);
+        for (ChangeData cd : cds) {
+          fail("Failed to index change " + cd.getId(), true, null);
+        }
+      }
+    }
+
+    private List<String> getPaths(List<DiffEntry> filenames) {
+      Set<String> paths = Sets.newTreeSet();
+      for (DiffEntry e : filenames) {
+        if (e.getOldPath() != null) {
+          paths.add(e.getOldPath());
+        }
+        if (e.getNewPath() != null) {
+          paths.add(e.getNewPath());
+        }
+      }
+      return ImmutableList.copyOf(paths);
+    }
+
+    private RevTree aFor(RevCommit b, RevWalk walk) throws IOException {
+      switch (b.getParentCount()) {
+        case 0:
+          return walk.parseTree(emptyTree());
+        case 1:
+          RevCommit a = b.getParent(0);
+          walk.parseBody(a);
+          return walk.parseTree(a.getTree());
+        case 2:
+          return PatchListLoader.automerge(repo, walk, b);
+        default:
+          return null;
+      }
+    }
+
+    private ObjectId emptyTree() throws IOException {
+      ObjectInserter oi = repo.newObjectInserter();
+      try {
+        ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
+        oi.flush();
+        return id;
+      } finally {
+        oi.release();
+      }
+    }
+
+    private void fail(String error, boolean failed, Exception e) {
+      if (failed) {
+        this.failed.update(1);
+      }
+
+      if (e != null) {
+        log.warn(error, e);
+      } else {
+        log.warn(error);
+      }
+
+      if (verboseWriter != null) {
+        verboseWriter.println(error);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
index f214dce..d62df95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.index;
 
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -30,11 +28,7 @@
 import com.google.gwtorm.server.OrmException;
 
 import java.io.IOException;
-import java.lang.reflect.Field;
-import java.lang.reflect.Modifier;
-import java.lang.reflect.ParameterizedType;
 import java.sql.Timestamp;
-import java.util.Map;
 import java.util.Set;
 
 /**
@@ -44,13 +38,8 @@
  * {@link ChangeQueryBuilder} for querying that field, and a method on
  * {@link ChangeData} used for populating the corresponding document fields in
  * the secondary index.
- * <p>
- * Used to generate a schema for index implementations that require one.
  */
 public class ChangeField {
-  /** Increment whenever making schema changes. */
-  public static final int SCHEMA_VERSION = 15;
-
   /** Legacy change ID. */
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
       new FieldDef.Single<ChangeData, Integer>("_id",
@@ -284,36 +273,4 @@
           return r;
         }
       };
-
-  public static final ImmutableMap<String, FieldDef<ChangeData, ?>> ALL;
-
-  static {
-    Map<String, FieldDef<ChangeData, ?>> fields = Maps.newHashMap();
-    for (Field f : ChangeField.class.getFields()) {
-      if (Modifier.isPublic(f.getModifiers())
-          && Modifier.isStatic(f.getModifiers())
-          && Modifier.isFinal(f.getModifiers())
-          && FieldDef.class.isAssignableFrom(f.getType())) {
-        ParameterizedType t = (ParameterizedType) f.getGenericType();
-        if (t.getActualTypeArguments()[0] == ChangeData.class) {
-          try {
-            @SuppressWarnings("unchecked")
-            FieldDef<ChangeData, ?> fd = (FieldDef<ChangeData, ?>) f.get(null);
-            fields.put(fd.getName(), fd);
-          } catch (IllegalArgumentException e) {
-            throw new ExceptionInInitializerError(e);
-          } catch (IllegalAccessException e) {
-            throw new ExceptionInInitializerError(e);
-          }
-        } else {
-          throw new ExceptionInInitializerError(
-              "non-ChangeData ChangeField: " + f);
-        }
-      }
-    }
-    if (fields.isEmpty()) {
-      throw new ExceptionInInitializerError("no ChangeFields found");
-    }
-    ALL = ImmutableMap.copyOf(fields);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
index 3745eb5..e411956 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
@@ -21,8 +21,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 
 /**
@@ -39,6 +37,11 @@
   /** Instance indicating secondary index is disabled. */
   public static final ChangeIndex DISABLED = new ChangeIndex() {
     @Override
+    public Schema<ChangeData> getSchema() {
+      return null;
+    }
+
+    @Override
     public ListenableFuture<Void> insert(ChangeData cd) throws IOException {
       return Futures.immediateFuture(null);
     }
@@ -59,17 +62,27 @@
     }
 
     @Override
-    public ChangeDataSource getSource(Predicate<ChangeData> p)
-        throws QueryParseException {
+    public ChangeDataSource getSource(Predicate<ChangeData> p) {
       throw new UnsupportedOperationException();
     }
 
     @Override
-    public void finishIndex() {
+    public void close() {
       // Do nothing.
     }
+
+    @Override
+    public void markReady(boolean ready) {
+      throw new UnsupportedOperationException();
+    }
   };
 
+  /** @return the schema version used by this index. */
+  public Schema<ChangeData> getSchema();
+
+  /** Close this index. */
+  public void close();
+
   /**
    * Insert a change document into the index.
    * <p>
@@ -129,11 +142,10 @@
       throws QueryParseException;
 
   /**
-   * Mark completion of indexing.
+   * Mark whether this index is up-to-date and ready to serve reads.
    *
-   * @throws ConfigInvalidException
+   * @param ready whether the index is ready
    * @throws IOException
    */
-  public void finishIndex() throws IOException,
-      ConfigInvalidException;
+  public void markReady(boolean ready) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
index c25174e..0aa3fc8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
@@ -29,6 +29,11 @@
  * compute some of the fields and/or update the index.
  */
 public abstract class ChangeIndexer {
+  public interface Factory {
+    ChangeIndexer create(ChangeIndex index);
+    ChangeIndexer create(IndexCollection indexes);
+  }
+
   /** Instance indicating secondary index is disabled. */
   public static final ChangeIndexer DISABLED = new ChangeIndexer(null) {
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexerImpl.java
index 2c060ed..1de5c99 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexerImpl.java
@@ -21,9 +21,10 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import com.google.inject.util.Providers;
 
 import org.slf4j.Logger;
@@ -41,19 +42,33 @@
   private static final Logger log =
       LoggerFactory.getLogger(ChangeIndexerImpl.class);
 
+  private final IndexCollection indexes;
   private final ChangeIndex index;
   private final SchemaFactory<ReviewDb> schemaFactory;
   private final ThreadLocalRequestContext context;
 
-  @Inject
+  @AssistedInject
   ChangeIndexerImpl(@IndexExecutor ListeningScheduledExecutorService executor,
-      ChangeIndex index,
       SchemaFactory<ReviewDb> schemaFactory,
-      ThreadLocalRequestContext context) {
+      ThreadLocalRequestContext context,
+      @Assisted ChangeIndex index) {
     super(executor);
-    this.index = index;
     this.schemaFactory = schemaFactory;
     this.context = context;
+    this.index = index;
+    this.indexes = null;
+  }
+
+  @AssistedInject
+  ChangeIndexerImpl(@IndexExecutor ListeningScheduledExecutorService executor,
+      SchemaFactory<ReviewDb> schemaFactory,
+      ThreadLocalRequestContext context,
+      @Assisted IndexCollection indexes) {
+    super(executor);
+    this.schemaFactory = schemaFactory;
+    this.context = context;
+    this.index = null;
+    this.indexes = indexes;
   }
 
   @Override
@@ -84,7 +99,13 @@
               throw new OutOfScopeException("No user during ChangeIndexer");
             }
           });
-          index.replace(cd);
+          if (indexes != null) {
+            for (ChangeIndex i : indexes.getWriteIndexes()) {
+              i.replace(cd); // TODO(dborowitz): Parallelize these
+            }
+          } else {
+            index.replace(cd);
+          }
           return null;
         } finally  {
           context.setContext(null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java
new file mode 100644
index 0000000..87a4df1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.package com.google.gerrit.server.git;
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
+import com.google.gerrit.server.query.change.ChangeData;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.util.Arrays;
+import java.util.Map;
+
+/** Secondary index schemas for changes. */
+public class ChangeSchemas {
+  @SuppressWarnings("unchecked")
+  static final Schema<ChangeData> V1 = release(
+        ChangeField.LEGACY_ID,
+        ChangeField.ID,
+        ChangeField.STATUS,
+        ChangeField.PROJECT,
+        ChangeField.REF,
+        ChangeField.TOPIC,
+        ChangeField.UPDATED,
+        ChangeField.SORTKEY,
+        ChangeField.FILE,
+        ChangeField.OWNER,
+        ChangeField.REVIEWER,
+        ChangeField.COMMIT,
+        ChangeField.TR,
+        ChangeField.LABEL,
+        ChangeField.REVIEWED,
+        ChangeField.COMMIT_MESSAGE,
+        ChangeField.COMMENT);
+
+  private static Schema<ChangeData> release(FieldDef<ChangeData, ?>... fields) {
+    return new Schema<ChangeData>(true, Arrays.asList(fields));
+  }
+
+  @SuppressWarnings("unused")
+  private static Schema<ChangeData> developer(FieldDef<ChangeData, ?>... fields) {
+    return new Schema<ChangeData>(false, Arrays.asList(fields));
+  }
+
+  public static final ImmutableMap<Integer, Schema<ChangeData>> ALL;
+
+  public static Schema<ChangeData> get(int version) {
+    Schema<ChangeData> schema = ALL.get(version);
+    checkArgument(schema != null, "Unrecognized schema version: %s", version);
+    return schema;
+  }
+
+  public static Schema<ChangeData> getLatest() {
+    return Iterables.getLast(ALL.values());
+  }
+
+  static {
+    Map<Integer, Schema<ChangeData>> all = Maps.newTreeMap();
+    for (Field f : ChangeSchemas.class.getDeclaredFields()) {
+      if (Modifier.isStatic(f.getModifiers())
+          && Modifier.isFinal(f.getModifiers())
+          && Schema.class.isAssignableFrom(f.getType())) {
+        ParameterizedType t = (ParameterizedType) f.getGenericType();
+        if (t.getActualTypeArguments()[0] == ChangeData.class) {
+          try {
+            @SuppressWarnings("unchecked")
+            Schema<ChangeData> schema = (Schema<ChangeData>) f.get(null);
+            checkArgument(f.getName().startsWith("V"));
+            schema.setVersion(Integer.parseInt(f.getName().substring(1)));
+            all.put(schema.getVersion(), schema);
+          } catch (IllegalArgumentException e) {
+            throw new ExceptionInInitializerError(e);
+          } catch (IllegalAccessException e) {
+            throw new ExceptionInInitializerError(e);
+          }
+        } else {
+          throw new ExceptionInInitializerError(
+              "non-ChangeData schema: " + f);
+        }
+      }
+    }
+    if (all.isEmpty()) {
+      throw new ExceptionInInitializerError("no ChangeSchemas found");
+    }
+    ALL = ImmutableMap.copyOf(all);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
new file mode 100644
index 0000000..09561208
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.package com.google.gerrit.server.git;
+
+package com.google.gerrit.server.index;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.annotation.Nullable;
+
+/** Dynamic pointers to the index versions used for searching and writing. */
+@Singleton
+public class IndexCollection implements LifecycleListener {
+  private final CopyOnWriteArrayList<ChangeIndex> writeIndexes;
+  private final AtomicReference<ChangeIndex> searchIndex;
+
+  @Inject
+  @VisibleForTesting
+  public IndexCollection() {
+    this.writeIndexes = Lists.newCopyOnWriteArrayList();
+    this.searchIndex = new AtomicReference<ChangeIndex>();
+  }
+
+  /**
+   * @return the current search index version, or null if the secondary index is
+   *     disabled.
+   */
+  @Nullable
+  public ChangeIndex getSearchIndex() {
+    return searchIndex.get();
+  }
+
+  public void setSearchIndex(ChangeIndex index) {
+    searchIndex.set(index);
+  }
+
+  public Collection<ChangeIndex> getWriteIndexes() {
+    return Collections.unmodifiableCollection(writeIndexes);
+  }
+
+  public synchronized void addWriteIndex(ChangeIndex index) {
+    int version = index.getSchema().getVersion();
+    for (ChangeIndex i : writeIndexes) {
+      if (i.getSchema().getVersion() == version) {
+        throw new IllegalArgumentException(
+            "Write index version " + version + " already in list");
+      }
+    }
+    writeIndexes.add(index);
+  }
+
+  public synchronized void removeWriteIndex(int version) {
+    int removeIndex = -1;
+    for (int i = 0; i < writeIndexes.size(); i++) {
+      if (writeIndexes.get(i).getSchema().getVersion() == version) {
+        removeIndex = i;
+        break;
+      }
+    }
+    if (removeIndex >= 0) {
+      writeIndexes.remove(removeIndex);
+    }
+  }
+
+  public ChangeIndex getWriteIndex(int version) {
+    for (ChangeIndex i : writeIndexes) {
+      if (i.getSchema().getVersion() == version) {
+        return i;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public void start() {
+  }
+
+  @Override
+  public void stop() {
+    ChangeIndex read = searchIndex.get();
+    if (read != null) {
+      read.close();
+    }
+    for (ChangeIndex write : writeIndexes) {
+      if (write != read) {
+        write.close();
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index 5bdbd96..f46fe7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -16,16 +16,16 @@
 
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Executor;
-import com.google.gerrit.server.query.change.IndexRewrite;
-import com.google.gerrit.server.query.change.IndexRewriteImpl;
-import com.google.inject.AbstractModule;
+import com.google.gerrit.server.query.change.ChangeQueryRewriter;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 import org.eclipse.jgit.lib.Config;
 
@@ -35,7 +35,7 @@
  * This module should not be used directly except by specific secondary indexer
  * implementations (e.g. Lucene).
  */
-public class IndexModule extends AbstractModule {
+public class IndexModule extends LifecycleModule {
   public enum IndexType {
     SQL, LUCENE, SOLR;
   }
@@ -55,8 +55,13 @@
 
   @Override
   protected void configure() {
-    bind(ChangeIndexer.class).to(ChangeIndexerImpl.class);
-    bind(IndexRewrite.class).to(IndexRewriteImpl.class);
+    bind(ChangeQueryRewriter.class).to(IndexRewriteImpl.class);
+    bind(IndexRewriteImpl.BasicRewritesImpl.class);
+    bind(IndexCollection.class);
+    listener().to(IndexCollection.class);
+    install(new FactoryModuleBuilder()
+        .implement(ChangeIndexer.class, ChangeIndexerImpl.class)
+        .build(ChangeIndexer.Factory.class));
   }
 
   @Provides
@@ -77,4 +82,11 @@
     }
     return MoreExecutors.listeningDecorator(executor);
   }
+
+  @Provides
+  ChangeIndexer getChangeIndexer(
+      ChangeIndexer.Factory factory,
+      IndexCollection indexes) {
+    return factory.create(indexes);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
index 82e3aeb..d3b9e95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
@@ -37,12 +37,4 @@
   public FieldType<?> getType() {
     return def.getType();
   }
-
-  /**
-   * @return whether this predicate can only be satisfied by looking at the
-   *     secondary index, i.e. it cannot be expressed as a query over the DB.
-   */
-  public boolean isIndexOnly() {
-    return false;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriteImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriteImpl.java
new file mode 100644
index 0000000..e0c4fe9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriteImpl.java
@@ -0,0 +1,273 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.NotPredicate;
+import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryRewriter;
+import com.google.gerrit.server.query.change.AndSource;
+import com.google.gerrit.server.query.change.BasicChangeRewrites;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryRewriter;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.OrSource;
+import com.google.gerrit.server.query.change.SqlRewriterImpl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.util.BitSet;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+/** Rewriter that pushes boolean logic into the secondary index. */
+public class IndexRewriteImpl implements ChangeQueryRewriter {
+  /** Set of all open change statuses. */
+  public static final Set<Change.Status> OPEN_STATUSES;
+
+  /** Set of all closed change statuses. */
+  public static final Set<Change.Status> CLOSED_STATUSES;
+
+  static {
+    EnumSet<Change.Status> open = EnumSet.noneOf(Change.Status.class);
+    EnumSet<Change.Status> closed = EnumSet.noneOf(Change.Status.class);
+    for (Change.Status s : Change.Status.values()) {
+      if (s.isOpen()) {
+        open.add(s);
+      } else {
+        closed.add(s);
+      }
+    }
+    OPEN_STATUSES = Sets.immutableEnumSet(open);
+    CLOSED_STATUSES = Sets.immutableEnumSet(closed);
+  }
+
+  /**
+   * Get the set of statuses that changes matching the given predicate may have.
+   *
+   * @param in predicate
+   * @return the maximal set of statuses that any changes matching the input
+   *     predicates may have, based on examining boolean and
+   *     {@link ChangeStatusPredicate}s.
+   */
+  public static EnumSet<Change.Status> getPossibleStatus(Predicate<ChangeData> in) {
+    EnumSet<Change.Status> s = extractStatus(in);
+    return s != null ? s : EnumSet.allOf(Change.Status.class);
+  }
+
+  private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
+    if (in instanceof ChangeStatusPredicate) {
+      return EnumSet.of(((ChangeStatusPredicate) in).getStatus());
+    } else if (in instanceof NotPredicate) {
+      EnumSet<Status> s = extractStatus(in.getChild(0));
+      return s != null ? EnumSet.complementOf(s) : null;
+    } else if (in instanceof OrPredicate) {
+      EnumSet<Change.Status> r = null;
+      int childrenWithStatus = 0;
+      for (int i = 0; i < in.getChildCount(); i++) {
+        EnumSet<Status> c = extractStatus(in.getChild(i));
+        if (c != null) {
+          if (r == null) {
+            r = EnumSet.noneOf(Change.Status.class);
+          }
+          r.addAll(c);
+          childrenWithStatus++;
+        }
+      }
+      if (r != null && childrenWithStatus < in.getChildCount()) {
+        // At least one child supplied a status but another did not.
+        // Assume all statuses for the children that did not feed a
+        // status at this part of the tree. This matches behavior if
+        // the child was used at the root of a query.
+        return EnumSet.allOf(Change.Status.class);
+      }
+      return r;
+    } else if (in instanceof AndPredicate) {
+      EnumSet<Change.Status> r = null;
+      for (int i = 0; i < in.getChildCount(); i++) {
+        EnumSet<Change.Status> c = extractStatus(in.getChild(i));
+        if (c != null) {
+          if (r == null) {
+            r = EnumSet.allOf(Change.Status.class);
+          }
+          r.retainAll(c);
+        }
+      }
+      return r;
+    }
+    return null;
+  }
+
+  private final IndexCollection indexes;
+  private final Provider<ReviewDb> db;
+  private final BasicRewritesImpl basicRewrites;
+
+  @Inject
+  IndexRewriteImpl(IndexCollection indexes,
+      Provider<ReviewDb> db,
+      BasicRewritesImpl basicRewrites) {
+    this.indexes = indexes;
+    this.db = db;
+    this.basicRewrites = basicRewrites;
+  }
+
+  @Override
+  public Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
+    in = basicRewrites.rewrite(in);
+
+    ChangeIndex index = indexes.getSearchIndex();
+    Predicate<ChangeData> out = rewriteImpl(in, index);
+    if (in == out || out instanceof IndexPredicate) {
+      return query(out, index);
+    } else if (out == null /* cannot rewrite */) {
+      return in;
+    } else {
+      return out;
+    }
+  }
+
+  /**
+   * Rewrite a single predicate subtree.
+   *
+   * @param in predicate to rewrite.
+   * @param index index whose schema determines which fields are indexed.
+   * @return {@code null} if no part of this subtree can be queried in the
+   *     index directly. {@code in} if this subtree and all its children can be
+   *     queried directly in the index. Otherwise, a predicate that is
+   *     semantically equivalent, with some of its subtrees wrapped to query the
+   *     index directly.
+   */
+  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in,
+      ChangeIndex index) {
+    if (isIndexPredicate(in, index)) {
+      return in;
+    } else if (!isRewritePossible(in)) {
+      return null; // magic to indicate "in" cannot be rewritten
+    }
+
+    int n = in.getChildCount();
+    BitSet isIndexed = new BitSet(n);
+    BitSet notIndexed = new BitSet(n);
+    BitSet rewritten = new BitSet(n);
+    List<Predicate<ChangeData>> newChildren = Lists.newArrayListWithCapacity(n);
+    for (int i = 0; i < n; i++) {
+      Predicate<ChangeData> c = in.getChild(i);
+      Predicate<ChangeData> nc = rewriteImpl(c, index);
+      if (nc == c) {
+        isIndexed.set(i);
+        newChildren.add(c);
+      } else if (nc == null /* cannot rewrite c */) {
+        notIndexed.set(i);
+        newChildren.add(c);
+      } else {
+        rewritten.set(i);
+        newChildren.add(nc);
+      }
+    }
+
+    if (isIndexed.cardinality() == n) {
+      return in; // All children are indexed, leave as-is for parent.
+    } else if (notIndexed.cardinality() == n) {
+      return null; // Can't rewrite any children, so cannot rewrite in.
+    } else if (rewritten.cardinality() == n) {
+      return in.copy(newChildren); // All children were rewritten.
+    }
+    return partitionChildren(in, newChildren, isIndexed, index);
+  }
+
+  private boolean isIndexPredicate(Predicate<ChangeData> in, ChangeIndex index) {
+    if (!(in instanceof IndexPredicate)) {
+      return false;
+    }
+    IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
+    return index.getSchema().getFields().containsKey(p.getField().getName());
+  }
+
+  private Predicate<ChangeData> partitionChildren(
+      Predicate<ChangeData> in,
+      List<Predicate<ChangeData>> newChildren,
+      BitSet isIndexed,
+      ChangeIndex index) {
+    if (isIndexed.cardinality() == 1) {
+      int i = isIndexed.nextSetBit(0);
+      newChildren.add(0, query(newChildren.remove(i), index));
+      return copy(in, newChildren);
+    }
+
+    // Group all indexed predicates into a wrapped subtree.
+    List<Predicate<ChangeData>> indexed =
+        Lists.newArrayListWithCapacity(isIndexed.cardinality());
+
+    List<Predicate<ChangeData>> all =
+        Lists.newArrayListWithCapacity(
+            newChildren.size() - isIndexed.cardinality() + 1);
+
+    for (int i = 0; i < newChildren.size(); i++) {
+      Predicate<ChangeData> c = newChildren.get(i);
+      if (isIndexed.get(i)) {
+        indexed.add(c);
+      } else {
+        all.add(c);
+      }
+    }
+    all.add(0, query(in.copy(indexed), index));
+    return copy(in, all);
+  }
+
+  private Predicate<ChangeData> copy(
+      Predicate<ChangeData> in,
+      List<Predicate<ChangeData>> all) {
+    if (in instanceof AndPredicate) {
+      return new AndSource(db, all);
+    } else if (in instanceof OrPredicate) {
+      return new OrSource(all);
+    }
+    return in.copy(all);
+  }
+
+  private IndexedChangeQuery query(Predicate<ChangeData> p, ChangeIndex index) {
+    try {
+      return new IndexedChangeQuery(index, p);
+    } catch (QueryParseException e) {
+      throw new IllegalStateException(
+          "Failed to convert " + p + " to index predicate", e);
+    }
+  }
+
+  private static boolean isRewritePossible(Predicate<ChangeData> p) {
+    return p.getChildCount() > 0 && (
+           p instanceof AndPredicate
+        || p instanceof OrPredicate
+        || p instanceof NotPredicate);
+  }
+
+  static class BasicRewritesImpl extends BasicChangeRewrites {
+    private static final QueryRewriter.Definition<ChangeData, BasicRewritesImpl> mydef =
+        new QueryRewriter.Definition<ChangeData, BasicRewritesImpl>(
+            BasicRewritesImpl.class, SqlRewriterImpl.BUILDER);
+    @Inject
+    BasicRewritesImpl(Provider<ReviewDb> db) {
+      super(mydef, db);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/PredicateWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
similarity index 83%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/PredicateWrapper.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
index 39eed81..175208a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/PredicateWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index;
 
 import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
@@ -35,18 +36,36 @@
  * the secondary index; such predicates must also implement
  * {@link ChangeDataSource} to be chosen by the query processor.
  */
-public class PredicateWrapper extends Predicate<ChangeData> implements
-    ChangeDataSource {
+public class IndexedChangeQuery extends Predicate<ChangeData>
+    implements ChangeDataSource {
   private final Predicate<ChangeData> pred;
   private final ChangeDataSource source;
 
-  public PredicateWrapper(ChangeIndex index, Predicate<ChangeData> pred)
+  public IndexedChangeQuery(ChangeIndex index, Predicate<ChangeData> pred)
       throws QueryParseException {
     this.pred = pred;
     this.source = index.getSource(pred);
   }
 
   @Override
+  public int getChildCount() {
+    return 1;
+  }
+
+  @Override
+  public Predicate<ChangeData> getChild(int i) {
+    if (i == 0) {
+      return pred;
+    }
+    throw new ArrayIndexOutOfBoundsException(i);
+  }
+
+  @Override
+  public List<Predicate<ChangeData>> getChildren() {
+    return ImmutableList.of(pred);
+  }
+
+  @Override
   public int getCardinality() {
     return source.getCardinality();
   }
@@ -115,11 +134,11 @@
   public boolean equals(Object other) {
     return other != null
         && getClass() == other.getClass()
-        && pred.equals(((PredicateWrapper) other).pred);
+        && pred.equals(((IndexedChangeQuery) other).pred);
   }
 
   @Override
   public String toString() {
-    return "index(" + pred + ")";
+    return "index(" + source + ")";
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/NoIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/NoIndexModule.java
index 3417534..8c552d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/NoIndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/NoIndexModule.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.index;
 
-import com.google.gerrit.server.query.change.IndexRewrite;
+import com.google.gerrit.server.query.change.ChangeQueryRewriter;
+import com.google.gerrit.server.query.change.SqlRewriterImpl;
 import com.google.inject.AbstractModule;
 
 public class NoIndexModule extends AbstractModule {
@@ -26,6 +27,6 @@
   protected void configure() {
     bind(ChangeIndex.class).toInstance(ChangeIndex.DISABLED);
     bind(ChangeIndexer.class).toInstance(ChangeIndexer.DISABLED);
-    bind(IndexRewrite.class).toInstance(IndexRewrite.DISABLED);
+    bind(ChangeQueryRewriter.class).to(SqlRewriterImpl.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
new file mode 100644
index 0000000..94d1f9c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.package com.google.gerrit.server.git;
+
+package com.google.gerrit.server.index;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
+
+/** Specific version of a secondary index schema. */
+public class Schema<T> {
+  private final boolean release;
+  private final ImmutableMap<String, FieldDef<T, ?>> fields;
+  private int version;
+
+  protected Schema(boolean release, Iterable<FieldDef<T, ?>> fields) {
+    this(0, release, fields);
+  }
+
+  @VisibleForTesting
+  public Schema(int version, boolean release,
+      Iterable<FieldDef<T, ?>> fields) {
+    this.version = version;
+    this.release = release;
+    ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
+    for (FieldDef<T, ?> f : fields) {
+      b.put(f.getName(), f);
+    }
+    this.fields = b.build();
+  }
+
+  public final boolean isRelease() {
+    return release;
+  }
+
+  public final int getVersion() {
+    return version;
+  }
+
+  public final ImmutableMap<String, FieldDef<T, ?>> getFields() {
+    return fields;
+  }
+
+  void setVersion(int version) {
+    this.version = version;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
index ce50002..dc09a9c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
@@ -87,6 +87,7 @@
 
     init();
     format();
+    appendText(velocifyFile("Footer.vm"));
     if (shouldSendMessage()) {
       if (fromId != null) {
         final Account fromUser = args.accountCache.get(fromId).getAccount();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
index a50e71c..6e5ef65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
@@ -160,7 +160,8 @@
     DashboardInfo info = new DashboardInfo(refName, path);
     info.project = project;
     info.definingProject = definingProject.getName();
-    info.title = replace(project, config.getString("dashboard", null, "title"));
+    String query = config.getString("dashboard", null, "title");
+    info.title = replace(project, query == null ? info.path : query);
     info.description = replace(project, config.getString("dashboard", null, "description"));
     info.foreach = config.getString("dashboard", null, "foreach");
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryRewriter.java
index b6a08d3..6088d8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryRewriter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query;
 
+import com.google.common.collect.Lists;
 import com.google.inject.name.Named;
 
 import java.lang.annotation.Annotation;
@@ -27,7 +28,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Comparator;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedList;
@@ -65,19 +66,13 @@
     private final List<RewriteRule<T>> rewriteRules;
 
     public Definition(Class<R> clazz, QueryBuilder<T> qb) {
-      rewriteRules = new ArrayList<RewriteRule<T>>();
+      rewriteRules = Lists.newArrayList();
 
       Class<?> c = clazz;
       while (c != QueryRewriter.class) {
-        final Method[] declared = c.getDeclaredMethods();
-        Arrays.sort(declared, new Comparator<Method>() {
-          @Override
-          public int compare(Method o1, Method o2) {
-            return o1.getName().compareTo(o2.getName());
-          }
-        });
+        Method[] declared = c.getDeclaredMethods();
         for (Method m : declared) {
-          final Rewrite rp = m.getAnnotation(Rewrite.class);
+          Rewrite rp = m.getAnnotation(Rewrite.class);
           if ((m.getModifiers() & Modifier.ABSTRACT) != Modifier.ABSTRACT
               && (m.getModifiers() & Modifier.PUBLIC) == Modifier.PUBLIC
               && rp != null) {
@@ -86,6 +81,7 @@
         }
         c = c.getSuperclass();
       }
+      Collections.sort(rewriteRules);
     }
   }
 
@@ -340,7 +336,7 @@
   }
 
   /** Applies a rewrite rule to a Predicate. */
-  protected interface RewriteRule<T> {
+  protected interface RewriteRule<T> extends Comparable<RewriteRule<T>> {
     /**
      * Apply a rewrite rule to the Predicate.
      *
@@ -463,6 +459,15 @@
       final String msg = "Cannot apply " + method.getName();
       return new IllegalArgumentException(msg, e);
     }
+
+    @Override
+    public int compareTo(RewriteRule<T> in) {
+      if (in instanceof MethodRewrite) {
+        return method.getName().compareTo(
+            ((MethodRewrite<T>) in).method.getName());
+      }
+      return 1;
+    }
   }
 
   private static <T> Predicate<T> removeDuplicates(Predicate<T> in) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
index 7f726a7..0555fc7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
@@ -34,7 +34,8 @@
 import java.util.Comparator;
 import java.util.List;
 
-class AndSource extends AndPredicate<ChangeData> implements ChangeDataSource {
+public class AndSource extends AndPredicate<ChangeData>
+    implements ChangeDataSource {
   private static final Comparator<Predicate<ChangeData>> CMP =
       new Comparator<Predicate<ChangeData>>() {
         @Override
@@ -75,7 +76,8 @@
   private final Provider<ReviewDb> db;
   private int cardinality = -1;
 
-  AndSource(Provider<ReviewDb> db, Collection<? extends Predicate<ChangeData>> that) {
+  public AndSource(Provider<ReviewDb> db,
+      Collection<? extends Predicate<ChangeData>> that) {
     super(sort(that));
     this.db = db;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BasicChangeRewrites.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BasicChangeRewrites.java
new file mode 100644
index 0000000..1d2c9d2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BasicChangeRewrites.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.query.IntPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryRewriter;
+import com.google.inject.OutOfScopeException;
+import com.google.inject.Provider;
+import com.google.inject.name.Named;
+
+public abstract class BasicChangeRewrites extends QueryRewriter<ChangeData> {
+  protected static final ChangeQueryBuilder BUILDER = new ChangeQueryBuilder(
+      new ChangeQueryBuilder.Arguments( //
+          new InvalidProvider<ReviewDb>(), //
+          new InvalidProvider<ChangeQueryRewriter>(), //
+          null, null, null, null, null, //
+          null, null, null, null, null), null);
+
+  protected final Provider<ReviewDb> dbProvider;
+
+  protected BasicChangeRewrites(
+      Definition<ChangeData, ? extends QueryRewriter<ChangeData>> def,
+      Provider<ReviewDb> dbProvider) {
+    super(def);
+    this.dbProvider = dbProvider;
+  }
+
+  @Rewrite("-status:open")
+  @NoCostComputation
+  public Predicate<ChangeData> r00_notOpen() {
+    return ChangeStatusPredicate.closed(dbProvider);
+  }
+
+  @Rewrite("-status:closed")
+  @NoCostComputation
+  public Predicate<ChangeData> r00_notClosed() {
+    return ChangeStatusPredicate.open(dbProvider);
+  }
+
+  @SuppressWarnings("unchecked")
+  @NoCostComputation
+  @Rewrite("-status:merged")
+  public Predicate<ChangeData> r00_notMerged() {
+    return or(ChangeStatusPredicate.open(dbProvider),
+        new ChangeStatusPredicate(dbProvider, Change.Status.ABANDONED));
+  }
+
+  @SuppressWarnings("unchecked")
+  @NoCostComputation
+  @Rewrite("-status:abandoned")
+  public Predicate<ChangeData> r00_notAbandoned() {
+    return or(ChangeStatusPredicate.open(dbProvider),
+        new ChangeStatusPredicate(dbProvider, Change.Status.MERGED));
+  }
+
+  @SuppressWarnings("unchecked")
+  @NoCostComputation
+  @Rewrite("sortkey_before:z A=(age:*)")
+  public Predicate<ChangeData> r00_ageToSortKey(@Named("A") AgePredicate a) {
+    String cut = ChangeUtil.sortKey(a.getCut(), Integer.MAX_VALUE);
+    return and(new SortKeyPredicate.Before(dbProvider, cut), a);
+  }
+
+  @NoCostComputation
+  @Rewrite("A=(limit:*) B=(limit:*)")
+  public Predicate<ChangeData> r00_smallestLimit(
+      @Named("A") IntPredicate<ChangeData> a,
+      @Named("B") IntPredicate<ChangeData> b) {
+    return a.intValue() <= b.intValue() ? a : b;
+  }
+
+  @NoCostComputation
+  @Rewrite("A=(sortkey_before:*) B=(sortkey_before:*)")
+  public Predicate<ChangeData> r00_oldestSortKey(
+      @Named("A") SortKeyPredicate.Before a,
+      @Named("B") SortKeyPredicate.Before b) {
+    return a.getValue().compareTo(b.getValue()) <= 0 ? a : b;
+  }
+
+  @NoCostComputation
+  @Rewrite("A=(sortkey_after:*) B=(sortkey_after:*)")
+  public Predicate<ChangeData> r00_newestSortKey(
+      @Named("A") SortKeyPredicate.After a, @Named("B") SortKeyPredicate.After b) {
+    return a.getValue().compareTo(b.getValue()) >= 0 ? a : b;
+  }
+
+  private static final class InvalidProvider<T> implements Provider<T> {
+    @Override
+    public T get() {
+      throw new OutOfScopeException("Not available at init");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 4f68185..52f299c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.ChangeIndex;
+import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
@@ -110,7 +111,8 @@
       new QueryBuilder.Definition<ChangeData, ChangeQueryBuilder>(
           ChangeQueryBuilder.class);
 
-  static class Arguments {
+  @VisibleForTesting
+  public static class Arguments {
     final Provider<ReviewDb> dbProvider;
     final Provider<ChangeQueryRewriter> rewriter;
     final IdentifiedUser.GenericFactory userFactory;
@@ -122,10 +124,11 @@
     final PatchListCache patchListCache;
     final GitRepositoryManager repoManager;
     final ProjectCache projectCache;
-    final ChangeIndex index;
+    final IndexCollection indexes;
 
     @Inject
-    Arguments(Provider<ReviewDb> dbProvider,
+    @VisibleForTesting
+    public Arguments(Provider<ReviewDb> dbProvider,
         Provider<ChangeQueryRewriter> rewriter,
         IdentifiedUser.GenericFactory userFactory,
         CapabilityControl.Factory capabilityControlFactory,
@@ -136,7 +139,7 @@
         PatchListCache patchListCache,
         GitRepositoryManager repoManager,
         ProjectCache projectCache,
-        ChangeIndex index) {
+        IndexCollection indexes) {
       this.dbProvider = dbProvider;
       this.rewriter = rewriter;
       this.userFactory = userFactory;
@@ -148,7 +151,7 @@
       this.patchListCache = patchListCache;
       this.repoManager = repoManager;
       this.projectCache = projectCache;
-      this.index = index;
+      this.indexes = indexes;
     }
   }
 
@@ -161,7 +164,7 @@
   private boolean allowFileRegex;
 
   @Inject
-  ChangeQueryBuilder(Arguments args, @Assisted CurrentUser currentUser) {
+  public ChangeQueryBuilder(Arguments args, @Assisted CurrentUser currentUser) {
     super(mydef);
     this.args = args;
     this.currentUser = currentUser;
@@ -203,10 +206,11 @@
 
   @Operator
   public Predicate<ChangeData> comment(String value) throws QueryParseException {
-    if (args.index == ChangeIndex.DISABLED) {
+    ChangeIndex index = args.indexes.getSearchIndex();
+    if (index == null) {
       throw error("secondary index must be enabled for comment:" + value);
     }
-    return new CommentPredicate(args.dbProvider, args.index, value);
+    return new CommentPredicate(args.dbProvider, index, value);
   }
 
   @Operator
@@ -320,13 +324,13 @@
   @Operator
   public Predicate<ChangeData> file(String file) throws QueryParseException {
     if (file.startsWith("^")) {
-      if (allowFileRegex || args.index != ChangeIndex.DISABLED) {
+      if (allowFileRegex || args.indexes.getSearchIndex() != null) {
         return new RegexFilePredicate(args.dbProvider, args.patchListCache, file);
       } else {
         throw error("secondary index must be enabled for file:" + file);
       }
     } else {
-      if (args.index == ChangeIndex.DISABLED) {
+      if (args.indexes.getSearchIndex() == null) {
         throw error("secondary index must be enabled for file:" + file);
       }
       return new EqualsFilePredicate(args.dbProvider, args.patchListCache, file);
@@ -389,11 +393,12 @@
 
   @Operator
   public Predicate<ChangeData> message(String text) throws QueryParseException {
-    if (args.index == ChangeIndex.DISABLED) {
+    ChangeIndex index = args.indexes.getSearchIndex();
+    if (index == null) {
       throw error("secondary index must be enabled for message:" + text);
     }
 
-    return new MessagePredicate(args.dbProvider, args.index, text);
+    return new MessagePredicate(args.dbProvider, index, text);
   }
 
   @Operator
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryRewriter.java
index 2988021..1d6de6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryRewriter.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2013 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,701 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.query.IntPredicate;
 import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryRewriter;
-import com.google.gerrit.server.query.RewritePredicate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.name.Named;
 
-import java.util.Collection;
-
-public class ChangeQueryRewriter extends QueryRewriter<ChangeData> {
-  private static final QueryRewriter.Definition<ChangeData, ChangeQueryRewriter> mydef =
-      new QueryRewriter.Definition<ChangeData, ChangeQueryRewriter>(
-          ChangeQueryRewriter.class, new ChangeQueryBuilder(
-              new ChangeQueryBuilder.Arguments( //
-                  new InvalidProvider<ReviewDb>(), //
-                  new InvalidProvider<ChangeQueryRewriter>(), //
-                  null, null, null, null, null, //
-                  null, null, null, null, null), null));
-
-  private final Provider<ReviewDb> dbProvider;
-  private final IndexRewrite indexRewrite;
-
-  @Inject
-  ChangeQueryRewriter(Provider<ReviewDb> dbProvider,
-      IndexRewrite indexRewrite) {
-    super(mydef);
-    this.dbProvider = dbProvider;
-    this.indexRewrite = indexRewrite;
-  }
-
-  @Override
-  public Predicate<ChangeData> and(Collection<? extends Predicate<ChangeData>> l) {
-    return hasSource(l) ? new AndSource(dbProvider, l) : super.and(l);
-  }
-
-  @Override
-  public Predicate<ChangeData> or(Collection<? extends Predicate<ChangeData>> l) {
-    return hasSource(l) ? new OrSource(l) : super.or(l);
-  }
-
-  @Override
-  public Predicate<ChangeData> preRewrite(Predicate<ChangeData> in) {
-    return indexRewrite.rewrite(in);
-  }
-
-  @Rewrite("-status:open")
-  @NoCostComputation
-  public Predicate<ChangeData> r00_notOpen() {
-    return ChangeStatusPredicate.closed(dbProvider);
-  }
-
-  @Rewrite("-status:closed")
-  @NoCostComputation
-  public Predicate<ChangeData> r00_notClosed() {
-    return ChangeStatusPredicate.open(dbProvider);
-  }
-
-  @SuppressWarnings("unchecked")
-  @NoCostComputation
-  @Rewrite("-status:merged")
-  public Predicate<ChangeData> r00_notMerged() {
-    return or(ChangeStatusPredicate.open(dbProvider),
-        new ChangeStatusPredicate(dbProvider, Change.Status.ABANDONED));
-  }
-
-  @SuppressWarnings("unchecked")
-  @NoCostComputation
-  @Rewrite("-status:abandoned")
-  public Predicate<ChangeData> r00_notAbandoned() {
-    return or(ChangeStatusPredicate.open(dbProvider),
-        new ChangeStatusPredicate(dbProvider, Change.Status.MERGED));
-  }
-
-  @SuppressWarnings("unchecked")
-  @NoCostComputation
-  @Rewrite("sortkey_before:z A=(age:*)")
-  public Predicate<ChangeData> r00_ageToSortKey(@Named("A") AgePredicate a) {
-    String cut = ChangeUtil.sortKey(a.getCut(), Integer.MAX_VALUE);
-    return and(new SortKeyPredicate.Before(dbProvider, cut), a);
-  }
-
-  @NoCostComputation
-  @Rewrite("A=(limit:*) B=(limit:*)")
-  public Predicate<ChangeData> r00_smallestLimit(
-      @Named("A") IntPredicate<ChangeData> a,
-      @Named("B") IntPredicate<ChangeData> b) {
-    return a.intValue() <= b.intValue() ? a : b;
-  }
-
-  @NoCostComputation
-  @Rewrite("A=(sortkey_before:*) B=(sortkey_before:*)")
-  public Predicate<ChangeData> r00_oldestSortKey(
-      @Named("A") SortKeyPredicate.Before a,
-      @Named("B") SortKeyPredicate.Before b) {
-    return a.getValue().compareTo(b.getValue()) <= 0 ? a : b;
-  }
-
-  @NoCostComputation
-  @Rewrite("A=(sortkey_after:*) B=(sortkey_after:*)")
-  public Predicate<ChangeData> r00_newestSortKey(
-      @Named("A") SortKeyPredicate.After a, @Named("B") SortKeyPredicate.After b) {
-    return a.getValue().compareTo(b.getValue()) >= 0 ? a : b;
-  }
-
-  @Rewrite("status:open P=(project:*) B=(ref:*)")
-  public Predicate<ChangeData> r05_byBranchOpen(
-      @Named("P") final ProjectPredicate p,
-      @Named("B") final RefPredicate b) {
-    return new ChangeSource(500) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a)
-          throws OrmException {
-        return a.byBranchOpenAll(
-            new Branch.NameKey(p.getValueKey(), b.getValue()));
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus().isOpen()
-            && p.match(cd)
-            && b.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:merged P=(project:*) B=(ref:*) S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r05_byBranchMergedPrev(
-      @Named("P") final ProjectPredicate p,
-      @Named("B") final RefPredicate b,
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byBranchClosedPrev(Change.Status.MERGED.getCode(), //
-            new Branch.NameKey(p.getValueKey(), b.getValue()), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
-            && p.match(cd) //
-            && b.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:merged P=(project:*) B=(ref:*) S=(sortkey_before:*) L=(limit:*)")
-  public Predicate<ChangeData> r05_byBranchMergedNext(
-      @Named("P") final ProjectPredicate p,
-      @Named("B") final RefPredicate b,
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byBranchClosedNext(Change.Status.MERGED.getCode(), //
-            new Branch.NameKey(p.getValueKey(), b.getValue()), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
-            && p.match(cd) //
-            && b.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:open P=(project:*) S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r10_byProjectOpenPrev(
-      @Named("P") final ProjectPredicate p,
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(500, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byProjectOpenPrev(p.getValueKey(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus().isOpen() //
-            && p.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:open P=(project:*) S=(sortkey_before:*) L=(limit:*)")
-  public Predicate<ChangeData> r10_byProjectOpenNext(
-      @Named("P") final ProjectPredicate p,
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(500, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byProjectOpenNext(p.getValueKey(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus().isOpen() //
-            && p.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:merged P=(project:*) S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r10_byProjectMergedPrev(
-      @Named("P") final ProjectPredicate p,
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byProjectClosedPrev(Change.Status.MERGED.getCode(), //
-            p.getValueKey(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
-            && p.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:merged P=(project:*) S=(sortkey_before:*) L=(limit:*)")
-  public Predicate<ChangeData> r10_byProjectMergedNext(
-      @Named("P") final ProjectPredicate p,
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byProjectClosedNext(Change.Status.MERGED.getCode(), //
-            p.getValueKey(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
-            && p.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:abandoned P=(project:*) S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r10_byProjectAbandonedPrev(
-      @Named("P") final ProjectPredicate p,
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byProjectClosedPrev(Change.Status.ABANDONED.getCode(), //
-            p.getValueKey(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
-            && p.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:abandoned P=(project:*) S=(sortkey_before:*) L=(limit:*)")
-  public Predicate<ChangeData> r10_byProjectAbandonedNext(
-      @Named("P") final ProjectPredicate p,
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.byProjectClosedNext(Change.Status.ABANDONED.getCode(), //
-            p.getValueKey(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
-            && p.match(cd) //
-            && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:open S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byOpenPrev(
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(2000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.allOpenPrev(key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus().isOpen() && s.match(cd);
-      }
-    };
-  }
-
-  @Rewrite("status:open S=(sortkey_before:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byOpenNext(
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(2000, s.getValue(), l.intValue()) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.allOpenNext(key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus().isOpen() && s.match(cd);
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:merged S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byMergedPrev(
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
-      {
-        init("r20_byMergedPrev", s, l);
-      }
-
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.allClosedPrev(Change.Status.MERGED.getCode(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
-            && s.match(cd);
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:merged S=(sortkey_before:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byMergedNext(
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
-      {
-        init("r20_byMergedNext", s, l);
-      }
-
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.allClosedNext(Change.Status.MERGED.getCode(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
-            && s.match(cd);
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:abandoned S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byAbandonedPrev(
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
-      {
-        init("r20_byAbandonedPrev", s, l);
-      }
-
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.allClosedPrev(Change.Status.ABANDONED.getCode(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
-            && s.match(cd);
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:abandoned S=(sortkey_before:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byAbandonedNext(
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
-      {
-        init("r20_byAbandonedNext", s, l);
-      }
-
-      @Override
-      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-          throws OrmException {
-        return a.allClosedNext(Change.Status.ABANDONED.getCode(), key, limit);
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
-            && s.match(cd);
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:closed S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byClosedPrev(
-      @Named("S") final SortKeyPredicate.After s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return or(r20_byMergedPrev(s, l), r20_byAbandonedPrev(s, l));
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:closed S=(sortkey_after:*) L=(limit:*)")
-  public Predicate<ChangeData> r20_byClosedNext(
-      @Named("S") final SortKeyPredicate.Before s,
-      @Named("L") final IntPredicate<ChangeData> l) {
-    return or(r20_byMergedNext(s, l), r20_byAbandonedNext(s, l));
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:open O=(owner:*)")
-  public Predicate<ChangeData> r25_byOwnerOpen(
-      @Named("O") final OwnerPredicate o) {
-    return new ChangeSource(50) {
-      {
-        init("r25_byOwnerOpen", o);
-      }
-
-      @Override
-      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
-        return a.byOwnerOpen(o.getAccountId());
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus().isOpen() && o.match(cd);
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:closed O=(owner:*)")
-  public Predicate<ChangeData> r25_byOwnerClosed(
-      @Named("O") final OwnerPredicate o) {
-    return new ChangeSource(5000) {
-      {
-        init("r25_byOwnerClosed", o);
-      }
-
-      @Override
-      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
-        return a.byOwnerClosedAll(o.getAccountId());
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus().isClosed() && o.match(cd);
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("O=(owner:*)")
-  public Predicate<ChangeData> r26_byOwner(@Named("O") OwnerPredicate o) {
-    return or(r25_byOwnerOpen(o), r25_byOwnerClosed(o));
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:open R=(reviewer:*)")
-  public Predicate<ChangeData> r30_byReviewerOpen(
-      @Named("R") final ReviewerPredicate r) {
-    return new Source() {
-      {
-        init("r30_byReviewerOpen", r);
-      }
-
-      @Override
-      public ResultSet<ChangeData> read() throws OrmException {
-        return ChangeDataResultSet.patchSetApproval(dbProvider.get()
-            .patchSetApprovals().openByUser(r.getAccountId()));
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        Change change = cd.change(dbProvider);
-        return change != null && change.getStatus().isOpen() && r.match(cd);
-      }
-
-      @Override
-      public int getCardinality() {
-        return 50;
-      }
-
-      @Override
-      public int getCost() {
-        return ChangeCosts.cost(ChangeCosts.APPROVALS_SCAN, getCardinality());
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("status:closed R=(reviewer:*)")
-  public Predicate<ChangeData> r30_byReviewerClosed(
-      @Named("R") final ReviewerPredicate r) {
-    return new Source() {
-      {
-        init("r30_byReviewerClosed", r);
-      }
-
-      @Override
-      public ResultSet<ChangeData> read() throws OrmException {
-        return ChangeDataResultSet.patchSetApproval(dbProvider.get()
-            .patchSetApprovals().closedByUserAll(r.getAccountId()));
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        Change change = cd.change(dbProvider);
-        return change != null && change.getStatus().isClosed() && r.match(cd);
-      }
-
-      @Override
-      public int getCardinality() {
-        return 5000;
-      }
-
-      @Override
-      public int getCost() {
-        return ChangeCosts.cost(ChangeCosts.APPROVALS_SCAN, getCardinality());
-      }
-    };
-  }
-
-  @SuppressWarnings("unchecked")
-  @Rewrite("R=(reviewer:*)")
-  public Predicate<ChangeData> r31_byReviewer(
-      @Named("R") final ReviewerPredicate r) {
-    return or(r30_byReviewerOpen(r), r30_byReviewerClosed(r));
-  }
-
-  @Rewrite("status:submitted")
-  public Predicate<ChangeData> r99_allSubmitted() {
-    return new ChangeSource(50) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
-        return a.allSubmitted();
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return cd.change(dbProvider).getStatus() == Change.Status.SUBMITTED;
-      }
-    };
-  }
-
-  @Rewrite("P=(project:*)")
-  public Predicate<ChangeData> r99_byProject(
-      @Named("P") final ProjectPredicate p) {
-    return new ChangeSource(1000000) {
-      @Override
-      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
-        return a.byProject(p.getValueKey());
-      }
-
-      @Override
-      public boolean match(ChangeData cd) throws OrmException {
-        return p.match(cd);
-      }
-    };
-  }
-
-  private static boolean hasSource(Collection<? extends Predicate<ChangeData>> l) {
-    for (Predicate<ChangeData> p : l) {
-      if (p instanceof ChangeDataSource) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private abstract static class Source extends RewritePredicate<ChangeData>
-      implements ChangeDataSource {
-    @Override
-    public boolean hasChange() {
-      return false;
-    }
-  }
-
-  private abstract class ChangeSource extends Source {
-    private final int cardinality;
-
-    ChangeSource(int card) {
-      this.cardinality = card;
-    }
-
-    abstract ResultSet<Change> scan(ChangeAccess a) throws OrmException;
-
-    @Override
-    public ResultSet<ChangeData> read() throws OrmException {
-      return ChangeDataResultSet.change(scan(dbProvider.get().changes()));
-    }
-
-    @Override
-    public boolean hasChange() {
-      return true;
-    }
-
-    @Override
-    public int getCardinality() {
-      return cardinality;
-    }
-
-    @Override
-    public int getCost() {
-      return ChangeCosts.cost(ChangeCosts.CHANGES_SCAN, getCardinality());
-    }
-  }
-
-  private abstract class PaginatedSource extends ChangeSource implements
-      Paginated {
-    private final String startKey;
-    private final int limit;
-
-    PaginatedSource(int card, String start, int lim) {
-      super(card);
-      this.startKey = start;
-      this.limit = lim;
-    }
-
-    @Override
-    public int limit() {
-      return limit;
-    }
-
-    @Override
-    ResultSet<Change> scan(ChangeAccess a) throws OrmException {
-      return scan(a, startKey, limit);
-    }
-
-    @Override
-    public ResultSet<ChangeData> restart(ChangeData last) throws OrmException {
-      return ChangeDataResultSet.change(scan(dbProvider.get().changes(), //
-          last.change(dbProvider).getSortKey(), //
-          limit));
-    }
-
-    abstract ResultSet<Change> scan(ChangeAccess a, String key, int limit)
-        throws OrmException;
-  }
-
-  private static final class InvalidProvider<T> implements Provider<T> {
-    @Override
-    public T get() {
-      throw new OutOfScopeException("Not available at init");
-    }
-  }
+public interface ChangeQueryRewriter {
+  Predicate<ChangeData> rewrite(Predicate<ChangeData> in);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 6d08db0..d5d6a92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -47,7 +47,7 @@
     VALUES = values.build();
   }
 
-  static Predicate<ChangeData> open(Provider<ReviewDb> dbProvider) {
+  public static Predicate<ChangeData> open(Provider<ReviewDb> dbProvider) {
     List<Predicate<ChangeData>> r = new ArrayList<Predicate<ChangeData>>(4);
     for (final Change.Status e : Change.Status.values()) {
       if (e.isOpen()) {
@@ -57,7 +57,7 @@
     return r.size() == 1 ? r.get(0) : or(r);
   }
 
-  static Predicate<ChangeData> closed(Provider<ReviewDb> dbProvider) {
+  public static Predicate<ChangeData> closed(Provider<ReviewDb> dbProvider) {
     List<Predicate<ChangeData>> r = new ArrayList<Predicate<ChangeData>>(4);
     for (final Change.Status e : Change.Status.values()) {
       if (e.isClosed()) {
@@ -83,7 +83,7 @@
     this.status = status;
   }
 
-  Change.Status getStatus() {
+  public Change.Status getStatus() {
     return status;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
index 1437dad..05d7573 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -55,9 +55,4 @@
   public int getCost() {
     return 1;
   }
-
-  @Override
-  public boolean isIndexOnly() {
-    return true;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index 1601bef..002dc99 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -54,9 +54,4 @@
   public int getCost() {
     return 1;
   }
-
-  @Override
-  public boolean isIndexOnly() {
-    return true;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IndexRewrite.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IndexRewrite.java
deleted file mode 100644
index 88c646b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IndexRewrite.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.query.Predicate;
-
-public interface IndexRewrite {
-  /** Instance indicating secondary index is disabled. */
-  public static final IndexRewrite DISABLED = new IndexRewrite() {
-    @Override
-    public Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
-      return in;
-    }
-  };
-
-  /**
-   * Rewrite a predicate to push as much boolean logic as possible into the
-   * secondary index query system.
-   *
-   * @param in predicate to rewrite.
-   * @return a predicate with some subtrees replaced with predicates that are
-   *     also sources that query the index directly.
-   */
-  public Predicate<ChangeData> rewrite(Predicate<ChangeData> in);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IndexRewriteImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IndexRewriteImpl.java
deleted file mode 100644
index 51f6208..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IndexRewriteImpl.java
+++ /dev/null
@@ -1,222 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.PredicateWrapper;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.NotPredicate;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.inject.Inject;
-
-import java.util.BitSet;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Set;
-
-/** Rewriter that pushes boolean logic into the secondary index. */
-public class IndexRewriteImpl implements IndexRewrite {
-  /** Set of all open change statuses. */
-  public static final Set<Change.Status> OPEN_STATUSES;
-
-  /** Set of all closed change statuses. */
-  public static final Set<Change.Status> CLOSED_STATUSES;
-
-  static {
-    EnumSet<Change.Status> open = EnumSet.noneOf(Change.Status.class);
-    EnumSet<Change.Status> closed = EnumSet.noneOf(Change.Status.class);
-    for (Change.Status s : Change.Status.values()) {
-      if (s.isOpen()) {
-        open.add(s);
-      } else {
-        closed.add(s);
-      }
-    }
-    OPEN_STATUSES = Sets.immutableEnumSet(open);
-    CLOSED_STATUSES = Sets.immutableEnumSet(closed);
-  }
-
-  /**
-   * Get the set of statuses that changes matching the given predicate may have.
-   *
-   * @param in predicate
-   * @return the maximal set of statuses that any changes matching the input
-   *     predicates may have, based on examining boolean and
-   *     {@link ChangeStatusPredicate}s.
-   */
-  public static EnumSet<Change.Status> getPossibleStatus(Predicate<ChangeData> in) {
-    if (in instanceof ChangeStatusPredicate) {
-      return EnumSet.of(((ChangeStatusPredicate) in).getStatus());
-    } else if (in instanceof NotPredicate) {
-      return EnumSet.complementOf(getPossibleStatus(in.getChild(0)));
-    } else if (in instanceof OrPredicate) {
-      EnumSet<Change.Status> s = EnumSet.noneOf(Change.Status.class);
-      for (int i = 0; i < in.getChildCount(); i++) {
-        s.addAll(getPossibleStatus(in.getChild(i)));
-      }
-      return s;
-    } else if (in instanceof AndPredicate) {
-      EnumSet<Change.Status> s = EnumSet.allOf(Change.Status.class);
-      for (int i = 0; i < in.getChildCount(); i++) {
-        s.retainAll(getPossibleStatus(in.getChild(i)));
-      }
-      return s;
-    } else if (in.getChildCount() == 0) {
-      return EnumSet.allOf(Change.Status.class);
-    } else {
-      throw new IllegalStateException(
-          "Invalid predicate type in change index query: " + in.getClass());
-    }
-  }
-
-  private final ChangeIndex index;
-
-  @Inject
-  IndexRewriteImpl(ChangeIndex index) {
-    this.index = index;
-  }
-
-  @Override
-  public Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
-    Predicate<ChangeData> out = rewriteImpl(in);
-    if (out == null) {
-      return in;
-    } else if (out == in) {
-      return wrap(out);
-    } else {
-      return out;
-    }
-  }
-
-  /**
-   * Rewrite a single predicate subtree.
-   *
-   * @param in predicate to rewrite.
-   * @return {@code null} if no part of this subtree can be queried in the
-   *     index directly. {@code in} if this subtree and all its children can be
-   *     queried directly in the index. Otherwise, a predicate that is
-   *     semantically equivalent, with some of its subtrees wrapped to query the
-   *     index directly.
-   */
-  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in) {
-    if (in instanceof IndexPredicate) {
-      return in;
-    }
-    if (!isBoolean(in)) {
-      return null;
-    }
-    int n = in.getChildCount();
-    BitSet toKeep = new BitSet(n);
-    BitSet toWrap = new BitSet(n);
-    BitSet rewritten = new BitSet(n);
-    List<Predicate<ChangeData>> newChildren = Lists.newArrayListWithCapacity(n);
-    for (int i = 0; i < n; i++) {
-      Predicate<ChangeData> c = in.getChild(i);
-      Predicate<ChangeData> nc = rewriteImpl(c);
-      if (nc == null) {
-        toKeep.set(i);
-        newChildren.add(c);
-      } else if (nc == c) {
-        toWrap.set(i);
-        newChildren.add(nc);
-      } else {
-        rewritten.set(i);
-        newChildren.add(nc);
-      }
-    }
-    if (toKeep.cardinality() == n) {
-      return null; // Can't rewrite any children.
-    }
-    if (rewritten.cardinality() == n) {
-      // All children were partially, but not fully, rewritten.
-      return in.copy(newChildren);
-    }
-    if (toWrap.cardinality() == n) {
-      // All children can be fully rewritten, push work to parent.
-      return in;
-    }
-    return partitionChildren(in, newChildren, toWrap);
-  }
-
-
-  private Predicate<ChangeData> partitionChildren(Predicate<ChangeData> in,
-      List<Predicate<ChangeData>> newChildren, BitSet toWrap) {
-    if (toWrap.cardinality() == 1) {
-      int i = toWrap.nextSetBit(0);
-      newChildren.set(i, wrap(newChildren.get(i)));
-      return in.copy(newChildren);
-    }
-
-    // Group all toWrap predicates into a wrapped subtree and place it as a
-    // sibling of the non-/partially-wrapped predicates. Assumes partitioning
-    // the children into arbitrary subtrees of the same type is logically
-    // equivalent to having them as siblings.
-    List<Predicate<ChangeData>> wrapped = Lists.newArrayListWithCapacity(
-        toWrap.cardinality());
-    List<Predicate<ChangeData>> all = Lists.newArrayListWithCapacity(
-        newChildren.size() - toWrap.cardinality() + 1);
-    for (int i = 0; i < newChildren.size(); i++) {
-      Predicate<ChangeData> child = newChildren.get(i);
-      if (toWrap.get(i)) {
-        wrapped.add(child);
-        if (allNonIndexOnly(child)) {
-          // Duplicate non-index-only predicate subtrees alongside the wrapped
-          // subtrees so they can provide index hints to the DB-based rewriter.
-          all.add(child);
-        }
-      } else {
-        all.add(child);
-      }
-    }
-    all.add(wrap(in.copy(wrapped)));
-    return in.copy(all);
-  }
-
-  private static boolean allNonIndexOnly(Predicate<ChangeData> p) {
-    if (p instanceof IndexPredicate) {
-      return !((IndexPredicate<ChangeData>) p).isIndexOnly();
-    }
-    if (isBoolean(p)) {
-      for (int i = 0; i < p.getChildCount(); i++) {
-        if (!allNonIndexOnly(p.getChild(i))) {
-          return false;
-        }
-      }
-      return true;
-    } else {
-      return true;
-    }
-  }
-
-  private PredicateWrapper wrap(Predicate<ChangeData> p) {
-    try {
-      return new PredicateWrapper(index, p);
-    } catch (QueryParseException e) {
-      throw new IllegalStateException(
-          "Failed to convert " + p + " to index predicate", e);
-    }
-  }
-
-  private static boolean isBoolean(Predicate<ChangeData> p) {
-    return p instanceof AndPredicate || p instanceof OrPredicate
-        || p instanceof NotPredicate;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 514c29f..62a3876 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -59,9 +59,4 @@
   public int getCost() {
     return 1;
   }
-
-  @Override
-  public boolean isIndexOnly() {
-    return true;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
index ec5195b..4f36777 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
@@ -25,10 +25,10 @@
 import java.util.Collection;
 import java.util.HashSet;
 
-class OrSource extends OrPredicate<ChangeData> implements ChangeDataSource {
+public class OrSource extends OrPredicate<ChangeData> implements ChangeDataSource {
   private int cardinality = -1;
 
-  OrSource(final Collection<? extends Predicate<ChangeData>> that) {
+  public OrSource(Collection<? extends Predicate<ChangeData>> that) {
     super(that);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SqlRewriterImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SqlRewriterImpl.java
new file mode 100644
index 0000000..78b3c95
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SqlRewriterImpl.java
@@ -0,0 +1,631 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.query.IntPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryRewriter;
+import com.google.gerrit.server.query.RewritePredicate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.name.Named;
+
+import java.util.Collection;
+
+public class SqlRewriterImpl extends BasicChangeRewrites
+    implements ChangeQueryRewriter {
+  private static final QueryRewriter.Definition<ChangeData, SqlRewriterImpl> mydef =
+      new QueryRewriter.Definition<ChangeData, SqlRewriterImpl>(
+          SqlRewriterImpl.class, BUILDER);
+
+  @Inject
+  SqlRewriterImpl(Provider<ReviewDb> dbProvider) {
+    super(mydef, dbProvider);
+  }
+
+  @Override
+  public Predicate<ChangeData> and(Collection<? extends Predicate<ChangeData>> l) {
+    return hasSource(l) ? new AndSource(dbProvider, l) : super.and(l);
+  }
+
+  @Override
+  public Predicate<ChangeData> or(Collection<? extends Predicate<ChangeData>> l) {
+    return hasSource(l) ? new OrSource(l) : super.or(l);
+  }
+
+  @Rewrite("status:open P=(project:*) B=(ref:*)")
+  public Predicate<ChangeData> r05_byBranchOpen(
+      @Named("P") final ProjectPredicate p,
+      @Named("B") final RefPredicate b) {
+    return new ChangeSource(500) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a)
+          throws OrmException {
+        return a.byBranchOpenAll(
+            new Branch.NameKey(p.getValueKey(), b.getValue()));
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus().isOpen()
+            && p.match(cd)
+            && b.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:merged P=(project:*) B=(ref:*) S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r05_byBranchMergedPrev(
+      @Named("P") final ProjectPredicate p,
+      @Named("B") final RefPredicate b,
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byBranchClosedPrev(Change.Status.MERGED.getCode(), //
+            new Branch.NameKey(p.getValueKey(), b.getValue()), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
+            && p.match(cd) //
+            && b.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:merged P=(project:*) B=(ref:*) S=(sortkey_before:*) L=(limit:*)")
+  public Predicate<ChangeData> r05_byBranchMergedNext(
+      @Named("P") final ProjectPredicate p,
+      @Named("B") final RefPredicate b,
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byBranchClosedNext(Change.Status.MERGED.getCode(), //
+            new Branch.NameKey(p.getValueKey(), b.getValue()), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
+            && p.match(cd) //
+            && b.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:open P=(project:*) S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r10_byProjectOpenPrev(
+      @Named("P") final ProjectPredicate p,
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(500, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byProjectOpenPrev(p.getValueKey(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus().isOpen() //
+            && p.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:open P=(project:*) S=(sortkey_before:*) L=(limit:*)")
+  public Predicate<ChangeData> r10_byProjectOpenNext(
+      @Named("P") final ProjectPredicate p,
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(500, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byProjectOpenNext(p.getValueKey(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus().isOpen() //
+            && p.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:merged P=(project:*) S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r10_byProjectMergedPrev(
+      @Named("P") final ProjectPredicate p,
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byProjectClosedPrev(Change.Status.MERGED.getCode(), //
+            p.getValueKey(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
+            && p.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:merged P=(project:*) S=(sortkey_before:*) L=(limit:*)")
+  public Predicate<ChangeData> r10_byProjectMergedNext(
+      @Named("P") final ProjectPredicate p,
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byProjectClosedNext(Change.Status.MERGED.getCode(), //
+            p.getValueKey(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
+            && p.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:abandoned P=(project:*) S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r10_byProjectAbandonedPrev(
+      @Named("P") final ProjectPredicate p,
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byProjectClosedPrev(Change.Status.ABANDONED.getCode(), //
+            p.getValueKey(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
+            && p.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:abandoned P=(project:*) S=(sortkey_before:*) L=(limit:*)")
+  public Predicate<ChangeData> r10_byProjectAbandonedNext(
+      @Named("P") final ProjectPredicate p,
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(40000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.byProjectClosedNext(Change.Status.ABANDONED.getCode(), //
+            p.getValueKey(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
+            && p.match(cd) //
+            && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:open S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byOpenPrev(
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(2000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.allOpenPrev(key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus().isOpen() && s.match(cd);
+      }
+    };
+  }
+
+  @Rewrite("status:open S=(sortkey_before:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byOpenNext(
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(2000, s.getValue(), l.intValue()) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.allOpenNext(key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus().isOpen() && s.match(cd);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:merged S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byMergedPrev(
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
+      {
+        init("r20_byMergedPrev", s, l);
+      }
+
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.allClosedPrev(Change.Status.MERGED.getCode(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
+            && s.match(cd);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:merged S=(sortkey_before:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byMergedNext(
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
+      {
+        init("r20_byMergedNext", s, l);
+      }
+
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.allClosedNext(Change.Status.MERGED.getCode(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.MERGED
+            && s.match(cd);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:abandoned S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byAbandonedPrev(
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
+      {
+        init("r20_byAbandonedPrev", s, l);
+      }
+
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.allClosedPrev(Change.Status.ABANDONED.getCode(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
+            && s.match(cd);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:abandoned S=(sortkey_before:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byAbandonedNext(
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return new PaginatedSource(50000, s.getValue(), l.intValue()) {
+      {
+        init("r20_byAbandonedNext", s, l);
+      }
+
+      @Override
+      ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+          throws OrmException {
+        return a.allClosedNext(Change.Status.ABANDONED.getCode(), key, limit);
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.ABANDONED
+            && s.match(cd);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:closed S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byClosedPrev(
+      @Named("S") final SortKeyPredicate.After s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return or(r20_byMergedPrev(s, l), r20_byAbandonedPrev(s, l));
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:closed S=(sortkey_after:*) L=(limit:*)")
+  public Predicate<ChangeData> r20_byClosedNext(
+      @Named("S") final SortKeyPredicate.Before s,
+      @Named("L") final IntPredicate<ChangeData> l) {
+    return or(r20_byMergedNext(s, l), r20_byAbandonedNext(s, l));
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:open O=(owner:*)")
+  public Predicate<ChangeData> r25_byOwnerOpen(
+      @Named("O") final OwnerPredicate o) {
+    return new ChangeSource(50) {
+      {
+        init("r25_byOwnerOpen", o);
+      }
+
+      @Override
+      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
+        return a.byOwnerOpen(o.getAccountId());
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus().isOpen() && o.match(cd);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:closed O=(owner:*)")
+  public Predicate<ChangeData> r25_byOwnerClosed(
+      @Named("O") final OwnerPredicate o) {
+    return new ChangeSource(5000) {
+      {
+        init("r25_byOwnerClosed", o);
+      }
+
+      @Override
+      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
+        return a.byOwnerClosedAll(o.getAccountId());
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus().isClosed() && o.match(cd);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("O=(owner:*)")
+  public Predicate<ChangeData> r26_byOwner(@Named("O") OwnerPredicate o) {
+    return or(r25_byOwnerOpen(o), r25_byOwnerClosed(o));
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:open R=(reviewer:*)")
+  public Predicate<ChangeData> r30_byReviewerOpen(
+      @Named("R") final ReviewerPredicate r) {
+    return new Source() {
+      {
+        init("r30_byReviewerOpen", r);
+      }
+
+      @Override
+      public ResultSet<ChangeData> read() throws OrmException {
+        return ChangeDataResultSet.patchSetApproval(dbProvider.get()
+            .patchSetApprovals().openByUser(r.getAccountId()));
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        Change change = cd.change(dbProvider);
+        return change != null && change.getStatus().isOpen() && r.match(cd);
+      }
+
+      @Override
+      public int getCardinality() {
+        return 50;
+      }
+
+      @Override
+      public int getCost() {
+        return ChangeCosts.cost(ChangeCosts.APPROVALS_SCAN, getCardinality());
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("status:closed R=(reviewer:*)")
+  public Predicate<ChangeData> r30_byReviewerClosed(
+      @Named("R") final ReviewerPredicate r) {
+    return new Source() {
+      {
+        init("r30_byReviewerClosed", r);
+      }
+
+      @Override
+      public ResultSet<ChangeData> read() throws OrmException {
+        return ChangeDataResultSet.patchSetApproval(dbProvider.get()
+            .patchSetApprovals().closedByUserAll(r.getAccountId()));
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        Change change = cd.change(dbProvider);
+        return change != null && change.getStatus().isClosed() && r.match(cd);
+      }
+
+      @Override
+      public int getCardinality() {
+        return 5000;
+      }
+
+      @Override
+      public int getCost() {
+        return ChangeCosts.cost(ChangeCosts.APPROVALS_SCAN, getCardinality());
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Rewrite("R=(reviewer:*)")
+  public Predicate<ChangeData> r31_byReviewer(
+      @Named("R") final ReviewerPredicate r) {
+    return or(r30_byReviewerOpen(r), r30_byReviewerClosed(r));
+  }
+
+  @Rewrite("status:submitted")
+  public Predicate<ChangeData> r99_allSubmitted() {
+    return new ChangeSource(50) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
+        return a.allSubmitted();
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return cd.change(dbProvider).getStatus() == Change.Status.SUBMITTED;
+      }
+    };
+  }
+
+  @Rewrite("P=(project:*)")
+  public Predicate<ChangeData> r99_byProject(
+      @Named("P") final ProjectPredicate p) {
+    return new ChangeSource(1000000) {
+      @Override
+      ResultSet<Change> scan(ChangeAccess a) throws OrmException {
+        return a.byProject(p.getValueKey());
+      }
+
+      @Override
+      public boolean match(ChangeData cd) throws OrmException {
+        return p.match(cd);
+      }
+    };
+  }
+
+  private static boolean hasSource(Collection<? extends Predicate<ChangeData>> l) {
+    for (Predicate<ChangeData> p : l) {
+      if (p instanceof ChangeDataSource) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private abstract static class Source extends RewritePredicate<ChangeData>
+      implements ChangeDataSource {
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+  }
+
+  private abstract class ChangeSource extends Source {
+    private final int cardinality;
+
+    ChangeSource(int card) {
+      this.cardinality = card;
+    }
+
+    abstract ResultSet<Change> scan(ChangeAccess a) throws OrmException;
+
+    @Override
+    public ResultSet<ChangeData> read() throws OrmException {
+      return ChangeDataResultSet.change(scan(dbProvider.get().changes()));
+    }
+
+    @Override
+    public boolean hasChange() {
+      return true;
+    }
+
+    @Override
+    public int getCardinality() {
+      return cardinality;
+    }
+
+    @Override
+    public int getCost() {
+      return ChangeCosts.cost(ChangeCosts.CHANGES_SCAN, getCardinality());
+    }
+  }
+
+  private abstract class PaginatedSource extends ChangeSource implements
+      Paginated {
+    private final String startKey;
+    private final int limit;
+
+    PaginatedSource(int card, String start, int lim) {
+      super(card);
+      this.startKey = start;
+      this.limit = lim;
+    }
+
+    @Override
+    public int limit() {
+      return limit;
+    }
+
+    @Override
+    ResultSet<Change> scan(ChangeAccess a) throws OrmException {
+      return scan(a, startKey, limit);
+    }
+
+    @Override
+    public ResultSet<ChangeData> restart(ChangeData last) throws OrmException {
+      return ChangeDataResultSet.change(scan(dbProvider.get().changes(), //
+          last.change(dbProvider).getSortKey(), //
+          limit));
+    }
+
+    abstract ResultSet<Change> scan(ChangeAccess a, String key, int limit)
+        throws OrmException;
+  }
+}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm
new file mode 100644
index 0000000..28f29fd
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm
@@ -0,0 +1,33 @@
+## Copyright (C) 2013 The Android Open Source Project
+##
+## Licensed under the Apache License, Version 2.0 (the "License");
+## you may not use this file except in compliance with the License.
+## You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+##
+##
+## Template Type:
+## -------------
+## This is a velocity mail template, see: http://velocity.apache.org and the
+## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
+##
+## Template File Names and extensions:
+## ----------------------------------
+## Gerrit will use templates ending in ".vm" but will ignore templates ending
+## in ".vm.example".  If a .vm template does not exist, the default internal
+## gerrit template which is the same as the .vm.example will be used.  If you
+## want to override the default template, copy the .vm.example file to a .vm
+## file and edit it appropriately.
+##
+## This Template:
+## --------------
+## The Footer.vm template will determine the contents of the footer text
+## appended to the end of all outgoing emails after the ChangeFooter and
+## CommentFooter.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/IndexRewriteTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriteTest.java
similarity index 67%
rename from gerrit-server/src/test/java/com/google/gerrit/server/query/change/IndexRewriteTest.java
rename to gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriteTest.java
index a754524..e91c3a0 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/IndexRewriteTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriteTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.index;
 
 import static com.google.gerrit.reviewdb.client.Change.Status.ABANDONED;
 import static com.google.gerrit.reviewdb.client.Change.Status.DRAFT;
@@ -23,13 +23,15 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.PredicateWrapper;
 import com.google.gerrit.server.query.AndPredicate;
 import com.google.gerrit.server.query.OperatorPredicate;
-import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.AndSource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.OrSource;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 
@@ -40,7 +42,22 @@
 
 @SuppressWarnings("unchecked")
 public class IndexRewriteTest extends TestCase {
+  private static Schema<ChangeData> V1 = new Schema<ChangeData>(
+      1, false, ImmutableList.<FieldDef<ChangeData, ?>> of(
+          ChangeField.STATUS));
+
+  private static Schema<ChangeData> V2 = new Schema<ChangeData>(
+      2, false, ImmutableList.of(
+          ChangeField.STATUS,
+          ChangeField.FILE));
+
   private static class DummyIndex implements ChangeIndex {
+    private final Schema<ChangeData> schema;
+
+    private DummyIndex(Schema<ChangeData> schema) {
+      this.schema = schema;
+    }
+
     @Override
     public ListenableFuture<Void> insert(ChangeData cd) {
       throw new UnsupportedOperationException();
@@ -64,16 +81,31 @@
     @Override
     public ChangeDataSource getSource(Predicate<ChangeData> p)
         throws QueryParseException {
-      return new Source();
+      return new Source(p);
     }
 
     @Override
-    public void finishIndex() {
+    public Schema<ChangeData> getSchema() {
+      return schema;
+    }
+
+    @Override
+    public void close() {
+    }
+
+    @Override
+    public void markReady(boolean ready) {
       throw new UnsupportedOperationException();
     }
   }
 
   private static class Source implements ChangeDataSource {
+    private final Predicate<ChangeData> p;
+
+    Source(Predicate<ChangeData> p) {
+      this.p = p;
+    }
+
     @Override
     public int getCardinality() {
       throw new UnsupportedOperationException();
@@ -88,15 +120,20 @@
     public ResultSet<ChangeData> read() throws OrmException {
       throw new UnsupportedOperationException();
     }
+
+    @Override
+    public String toString() {
+      return p.toString();
+    }
   }
 
-  public static class QueryBuilder extends ChangeQueryBuilder {
+  public class QueryBuilder extends ChangeQueryBuilder {
     QueryBuilder() {
       super(
           new QueryBuilder.Definition<ChangeData, QueryBuilder>(
             QueryBuilder.class),
           new ChangeQueryBuilder.Arguments(null, null, null, null, null, null,
-            null, null, null, null, null, null),
+            null, null, null, null, null, indexes),
           null);
     }
 
@@ -110,7 +147,7 @@
       return predicate("bar", value);
     }
 
-    private static Predicate<ChangeData> predicate(String name, String value) {
+    private Predicate<ChangeData> predicate(String name, String value) {
       return new OperatorPredicate<ChangeData>(name, value) {
         @Override
         public boolean match(ChangeData object) throws OrmException {
@@ -126,20 +163,26 @@
   }
 
   private DummyIndex index;
+  private IndexCollection indexes;
   private ChangeQueryBuilder queryBuilder;
-  private IndexRewrite rewrite;
+  private IndexRewriteImpl rewrite;
 
   @Override
   public void setUp() throws Exception {
     super.setUp();
-    index = new DummyIndex();
+    index = new DummyIndex(V2);
+    indexes = new IndexCollection();
+    indexes.setSearchIndex(index);
     queryBuilder = new QueryBuilder();
-    rewrite = new IndexRewriteImpl(index);
+    rewrite = new IndexRewriteImpl(
+        indexes,
+        null,
+        new IndexRewriteImpl.BasicRewritesImpl(null));
   }
 
   public void testIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("file:a");
-    assertEquals(wrap(in), rewrite(in));
+    assertEquals(query(in), rewrite(in));
   }
 
   public void testNonIndexPredicate() throws Exception {
@@ -149,33 +192,37 @@
 
   public void testIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("file:a file:b");
-    assertEquals(wrap(in), rewrite(in));
+    assertEquals(query(in), rewrite(in));
   }
 
   public void testNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("foo:a OR foo:b");
-    assertSame(in, rewrite(in));
+    assertEquals(in, rewrite(in));
   }
 
   public void testOneIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertSame(AndPredicate.class, out.getClass());
-    assertEquals(ImmutableList.of(in.getChild(0), wrap(in.getChild(1))),
+    assertSame(AndSource.class, out.getClass());
+    assertEquals(
+        ImmutableList.of(query(in.getChild(1)), in.getChild(0)),
         out.getChildren());
   }
 
   public void testThreeLevelTreeWithAllIndexPredicates() throws Exception {
     Predicate<ChangeData> in =
         parse("-status:abandoned (status:open OR status:merged)");
-    assertEquals(wrap(in), rewrite.rewrite(in));
+    assertEquals(
+        query(parse("status:new OR status:submitted OR status:draft OR status:merged")),
+        rewrite.rewrite(in));
   }
 
   public void testThreeLevelTreeWithSomeIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
-    assertEquals(AndPredicate.class, out.getClass());
-    assertEquals(ImmutableList.of(in.getChild(0), wrap(in.getChild(1))),
+    assertEquals(AndSource.class, out.getClass());
+    assertEquals(
+        ImmutableList.of(query(in.getChild(1)), in.getChild(0)),
         out.getChildren());
   }
 
@@ -183,20 +230,20 @@
     Predicate<ChangeData> in =
         parse("file:a OR foo:b OR file:c OR foo:d");
     Predicate<ChangeData> out = rewrite(in);
-    assertSame(OrPredicate.class, out.getClass());
+    assertSame(OrSource.class, out.getClass());
     assertEquals(ImmutableList.of(
-          in.getChild(1), in.getChild(3),
-          wrap(Predicate.or(in.getChild(0), in.getChild(2)))),
+          query(Predicate.or(in.getChild(0), in.getChild(2))),
+          in.getChild(1), in.getChild(3)),
         out.getChildren());
   }
 
-  public void testDuplicateSimpleNonIndexOnlyPredicates() throws Exception {
+  public void testIndexAndNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("status:new bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertSame(AndPredicate.class, out.getClass());
+    assertSame(AndSource.class, out.getClass());
     assertEquals(ImmutableList.of(
-          in.getChild(0), in.getChild(1),
-          wrap(Predicate.and(in.getChild(0), in.getChild(2)))),
+          query(Predicate.and(in.getChild(0), in.getChild(2))),
+          in.getChild(1)),
         out.getChildren());
   }
 
@@ -204,10 +251,10 @@
     Predicate<ChangeData> in =
         parse("(status:new OR status:draft) bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertSame(AndPredicate.class, out.getClass());
+    assertSame(AndSource.class, out.getClass());
     assertEquals(ImmutableList.of(
-          in.getChild(0), in.getChild(1),
-          wrap(Predicate.and(in.getChild(0), in.getChild(2)))),
+          query(Predicate.and(in.getChild(0), in.getChild(2))),
+          in.getChild(1)),
         out.getChildren());
   }
 
@@ -215,10 +262,10 @@
     Predicate<ChangeData> in =
         parse("(status:new OR file:a) bar:p file:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertSame(AndPredicate.class, out.getClass());
+    assertSame(AndSource.class, out.getClass());
     assertEquals(ImmutableList.of(
-          in.getChild(1),
-          wrap(Predicate.and(in.getChild(0), in.getChild(2)))),
+          query(Predicate.and(in.getChild(0), in.getChild(2))),
+          in.getChild(1)),
         out.getChildren());
   }
 
@@ -238,6 +285,19 @@
         status("(is:new is:draft) OR (is:merged OR is:submitted)"));
   }
 
+  public void testUnsupportedIndexOperator() throws Exception {
+    Predicate<ChangeData> in = parse("status:merged file:a");
+    assertEquals(query(in), rewrite(in));
+
+    indexes.setSearchIndex(new DummyIndex(V1));
+    Predicate<ChangeData> out = rewrite(in);
+    assertTrue(out instanceof AndPredicate);
+    assertEquals(ImmutableList.of(
+          query(in.getChild(0)),
+          in.getChild(1)),
+        out.getChildren());
+  }
+
   private Predicate<ChangeData> parse(String query) throws QueryParseException {
     return queryBuilder.parse(query);
   }
@@ -246,9 +306,9 @@
     return rewrite.rewrite(in);
   }
 
-  private PredicateWrapper wrap(Predicate<ChangeData> p)
+  private IndexedChangeQuery query(Predicate<ChangeData> p)
       throws QueryParseException {
-    return new PredicateWrapper(index, p);
+    return new IndexedChangeQuery(index, p);
   }
 
   private Set<Change.Status> status(String query) throws QueryParseException {
diff --git a/gerrit-solr/src/main/java/com/google/gerrit/solr/IndexVersionCheck.java b/gerrit-solr/src/main/java/com/google/gerrit/solr/IndexVersionCheck.java
index 67c4d0e..7e32bac 100644
--- a/gerrit-solr/src/main/java/com/google/gerrit/solr/IndexVersionCheck.java
+++ b/gerrit-solr/src/main/java/com/google/gerrit/solr/IndexVersionCheck.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.ChangeField;
+import com.google.gerrit.server.index.ChangeSchemas;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 
@@ -31,8 +31,8 @@
 
 class IndexVersionCheck implements LifecycleListener {
   public static final Map<String, Integer> SCHEMA_VERSIONS = ImmutableMap.of(
-      SolrChangeIndex.CHANGES_OPEN, ChangeField.SCHEMA_VERSION,
-      SolrChangeIndex.CHANGES_CLOSED, ChangeField.SCHEMA_VERSION);
+      SolrChangeIndex.CHANGES_OPEN, ChangeSchemas.getLatest().getVersion(),
+      SolrChangeIndex.CHANGES_CLOSED, ChangeSchemas.getLatest().getVersion());
 
   public static File solrIndexConfig(SitePaths sitePaths) {
     return new File(sitePaths.index_dir, "gerrit_index.config");
diff --git a/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrChangeIndex.java b/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrChangeIndex.java
index c28a0fc..b95978e 100644
--- a/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrChangeIndex.java
+++ b/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrChangeIndex.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.solr;
 
-import static com.google.gerrit.server.query.change.IndexRewriteImpl.CLOSED_STATUSES;
-import static com.google.gerrit.server.query.change.IndexRewriteImpl.OPEN_STATUSES;
+import static com.google.gerrit.server.index.IndexRewriteImpl.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.IndexRewriteImpl.OPEN_STATUSES;
 import static com.google.gerrit.solr.IndexVersionCheck.SCHEMA_VERSIONS;
 import static com.google.gerrit.solr.IndexVersionCheck.solrIndexConfig;
 
@@ -34,15 +34,15 @@
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.index.IndexRewriteImpl;
+import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.IndexRewriteImpl;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 
 import org.apache.lucene.search.Query;
 import org.apache.solr.client.solrj.SolrQuery;
@@ -52,7 +52,6 @@
 import org.apache.solr.common.SolrDocument;
 import org.apache.solr.common.SolrDocumentList;
 import org.apache.solr.common.SolrInputDocument;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -66,7 +65,6 @@
 import java.util.Set;
 
 /** Secondary index implementation using a remote Solr instance. */
-@Singleton
 class SolrChangeIndex implements ChangeIndex, LifecycleListener {
   public static final String CHANGES_OPEN = "changes_open";
   public static final String CHANGES_CLOSED = "changes_closed";
@@ -74,32 +72,40 @@
 
   private final FillArgs fillArgs;
   private final SitePaths sitePaths;
+  private final IndexCollection indexes;
   private final CloudSolrServer openIndex;
   private final CloudSolrServer closedIndex;
+  private final Schema<ChangeData> schema;
 
-  @Inject
   SolrChangeIndex(
       @GerritServerConfig Config cfg,
       FillArgs fillArgs,
-      SitePaths sitePaths) throws IOException {
+      SitePaths sitePaths,
+      IndexCollection indexes,
+      Schema<ChangeData> schema,
+      String base) throws IOException {
     this.fillArgs = fillArgs;
     this.sitePaths = sitePaths;
+    this.indexes = indexes;
+    this.schema = schema;
 
     String url = cfg.getString("index", "solr", "url");
     if (Strings.isNullOrEmpty(url)) {
       throw new IllegalStateException("index.solr.url must be supplied");
     }
 
+    base = Strings.nullToEmpty(base);
     openIndex = new CloudSolrServer(url);
-    openIndex.setDefaultCollection(CHANGES_OPEN);
+    openIndex.setDefaultCollection(base + CHANGES_OPEN);
 
     closedIndex = new CloudSolrServer(url);
-    closedIndex.setDefaultCollection(CHANGES_CLOSED);
+    closedIndex.setDefaultCollection(base + CHANGES_CLOSED);
   }
 
   @Override
   public void start() {
-    // Do nothing.
+    indexes.setSearchIndex(this);
+    indexes.addWriteIndex(this);
   }
 
   @Override
@@ -109,6 +115,16 @@
   }
 
   @Override
+  public Schema<ChangeData> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+    stop();
+  }
+
+  @Override
   public ListenableFuture<Void> insert(ChangeData cd) throws IOException {
     String id = cd.getId().toString();
     SolrInputDocument doc = toDocument(cd);
@@ -225,6 +241,11 @@
     }
 
     @Override
+    public String toString() {
+      return query.getQuery();
+    }
+
+    @Override
     public ResultSet<ChangeData> read() throws OrmException {
       try {
         // TODO Sort documents during merge to select only top N.
@@ -265,7 +286,7 @@
   private SolrInputDocument toDocument(ChangeData cd) throws IOException {
     try {
       SolrInputDocument result = new SolrInputDocument();
-      for (FieldDef<ChangeData, ?> f : ChangeField.ALL.values()) {
+      for (FieldDef<ChangeData, ?> f : schema.getFields().values()) {
         if (f.isRepeatable()) {
           add(result, f, (Iterable<?>) f.get(cd, fillArgs));
         } else {
@@ -304,14 +325,14 @@
   }
 
   @Override
-  public void finishIndex() throws IOException,
-      ConfigInvalidException {
+  public void markReady(boolean ready) throws IOException {
     // TODO Move the schema version information to a special meta-document
     FileBasedConfig cfg = new FileBasedConfig(
         solrIndexConfig(sitePaths),
         FS.detect());
     for (Map.Entry<String, Integer> e : SCHEMA_VERSIONS.entrySet()) {
-      cfg.setInt("index", e.getKey(), "schemaVersion", e.getValue());
+      cfg.setInt("index", e.getKey(), "schemaVersion",
+          ready ? e.getValue() : -1);
     }
     cfg.save();
   }
diff --git a/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrIndexModule.java b/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrIndexModule.java
index 4e1a548..8c614f7 100644
--- a/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrIndexModule.java
+++ b/gerrit-solr/src/main/java/com/google/gerrit/solr/SolrIndexModule.java
@@ -15,20 +15,33 @@
 package com.google.gerrit.solr;
 
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.ChangeIndex;
+import com.google.gerrit.server.index.ChangeSchemas;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
 
 public class SolrIndexModule extends LifecycleModule {
   private final boolean checkVersion;
   private final int threads;
+  private final String base;
 
   public SolrIndexModule() {
-    this(true, 0);
+    this(true, 0, null);
   }
 
-  public SolrIndexModule(boolean checkVersion, int threads) {
+  public SolrIndexModule(boolean checkVersion, int threads, String base) {
     this.checkVersion = checkVersion;
     this.threads = threads;
+    this.base = base;
   }
 
   @Override
@@ -40,4 +53,14 @@
       listener().to(IndexVersionCheck.class);
     }
   }
+
+  @Provides
+  @Singleton
+  public SolrChangeIndex getChangeIndex(@GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      IndexCollection indexes,
+      FillArgs fillArgs) throws IOException {
+    return new SolrChangeIndex(cfg, fillArgs, sitePaths, indexes,
+        ChangeSchemas.getLatest(), base);
+  }
 }
diff --git a/plugins/replication b/plugins/replication
index 5353cee..50972e3 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 5353ceeb979b166e641db8e9fb356eaadd6b50fd
+Subproject commit 50972e33baba9eaa034ef9637efaf27e928d7a46
diff --git a/website/releases/index.html b/website/releases/index.html
index 735bf1e..3faf054 100644
--- a/website/releases/index.html
+++ b/website/releases/index.html
@@ -45,6 +45,7 @@
   var frg = doc.createDocumentFragment();
   var rx = /^gerrit(?:-full)?-([0-9.]+(?:-rc[0-9]+)?)[.]war/;
   var rel = 'http://gerrit-documentation.googlecode.com/svn/ReleaseNotes/';
+  var src = 'https://gerrit.googlesource.com/gerrit/+/'
 
   data.items.sort(function(a,b) {
     var av = rx.exec(a.name);
@@ -101,6 +102,16 @@
     tr.appendChild(td);
 
     td = doc.createElement('td');
+    td.className = 'size';
+    if (f.size/(1024*1024) < 1) {
+      sizeText = Math.round(f.size/1024*10)/10 + ' KiB';
+    } else {
+      sizeText = Math.round(f.size/(1024*1024)*10)/10 + ' MiB';
+    }
+    td.appendChild(doc.createTextNode(sizeText));
+    tr.appendChild(td);
+
+    td = doc.createElement('td');
     if (v && f.name.indexOf('-rc') < 0) {
       a = doc.createElement('a');
       a.href = rel + 'ReleaseNotes-' + v[1] + '.html';
@@ -110,13 +121,12 @@
     tr.appendChild(td);
 
     td = doc.createElement('td');
-    td.className = 'size';
-    if (f.size/(1024*1024) < 1) {
-      sizeText = Math.round(f.size/1024*10)/10 + ' KiB';
-    } else {
-      sizeText = Math.round(f.size/(1024*1024)*10)/10 + ' MiB';
+    if (v) {
+      a = doc.createElement('a');
+      a.href = src + 'v' + v[1];
+      a.appendChild(doc.createTextNode('src'));
+      td.appendChild(a);
     }
-    td.appendChild(doc.createTextNode(sizeText));
     tr.appendChild(td);
 
     frg.appendChild(tr);
@@ -128,12 +138,12 @@
   tr.appendChild(th);
 
   th = doc.createElement('th');
-  tr.appendChild(th);
-
-  th = doc.createElement('th');
   th.appendChild(doc.createTextNode('Size'));
   tr.appendChild(th);
 
+  tr.appendChild(doc.createElement('th'));
+  tr.appendChild(doc.createElement('th'));
+
   var table = doc.createElement('table');
   table.appendChild(tr);
   table.appendChild(frg);