Merge "Move change id and status to left side of ChangeScreen2"
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.java
index 1cbd20d..4d457ad 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.java
@@ -20,6 +20,9 @@
   String openChange();
   String reviewedFileTitle();
 
+  String openLastFile();
+  String openCommitMessage();
+
   String patchSet();
   String commit();
   String date();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.properties
index 71d0bde..289e9b4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Constants.properties
@@ -3,6 +3,9 @@
 openChange = Open related change
 reviewedFileTitle = Mark file as reviewed (Shortcut: r)
 
+openLastFile = Open last file
+openCommitMessage = Open commit message
+
 patchSet = Patch Set
 commit = Commit
 date = Date
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
index c53619b..da1b3dc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
@@ -239,6 +239,10 @@
       keysNavigation.add(new OpenKeyCommand(0, KeyCodes.KEY_ENTER,
           Util.C.patchTableOpenDiff()));
 
+      keysNavigation.add(
+          new OpenFileCommand(list.length() - 1, 0, '[', Resources.C.openLastFile()),
+          new OpenFileCommand(0, 0, ']', Resources.C.openCommitMessage()));
+
       keysAction.add(new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
         @Override
         public void onKeyPress(KeyPressEvent event) {
@@ -330,6 +334,20 @@
         super.onCellSingleClick(event, row, column);
       }
     }
+
+    private class OpenFileCommand extends KeyCommand {
+      private final int index;
+
+      OpenFileCommand(int index, int modifiers, char c, String helpText) {
+        super(modifiers, c, helpText);
+        this.index = index;
+      }
+
+      @Override
+      public void onKeyPress(KeyPressEvent event) {
+        Gerrit.display(url(list.get(index)));
+      }
+    }
   }
 
   private final class DisplayCommand implements RepeatingCommand {
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
index 8efc9f7..67b86ed 100644
--- 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
@@ -14,6 +14,8 @@
  */
 
 .commentWidgets {
+  max-width: 650px;
+
   font-family: sans-serif;
   background-color: #fcfa96;
   border: 1px solid black;
@@ -38,7 +40,6 @@
 }
 
 .header {
-  max-width: 650px;
   cursor: pointer;
 }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
index 23630ea..3b712ad 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
@@ -148,11 +148,18 @@
       lineWidget = null;
       updateSelection();
     }
-    manager.clearLine(cm.side(), line);
+    manager.clearLine(cm.side(), line, this);
     removeFromParent();
   }
 
-  void attach(DiffTable parent) {
+  void attachPair(DiffTable parent) {
+    if (lineWidget == null && peer.lineWidget == null) {
+      this.attach(parent);
+      peer.attach(parent);
+    }
+  }
+
+  private void attach(DiffTable parent) {
     parent.add(this);
     lineWidget = cm.addLineWidget(Math.max(0, line - 1), getElement(),
         Configuration.create()
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index 03f8e2a..fef52c6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -134,10 +134,10 @@
       setExpandAllComments(true);
     }
     for (CommentGroup g : sideA.values()) {
-      g.attach(host.diffTable);
+      g.attachPair(host.diffTable);
     }
     for (CommentGroup g : sideB.values()) {
-      g.attach(host.diffTable);
+      g.attachPair(host.diffTable);
       g.handleRedraw();
     }
     attached = true;
@@ -280,8 +280,11 @@
     }
   }
 
-  void clearLine(DisplaySide side, int line) {
-    map(side).remove(line);
+  void clearLine(DisplaySide side, int line, CommentGroup group) {
+    SortedMap<Integer, CommentGroup> map = map(side);
+    if (map.get(line) == group) {
+      map.remove(line);
+    }
   }
 
   Runnable toggleOpenBox(final CodeMirror cm) {
@@ -398,8 +401,7 @@
     sideB.put(lineB, b);
 
     if (attached) {
-      a.attach(host.diffTable);
-      b.attach(host.diffTable);
+      a.attachPair(host.diffTable);
       b.handleRedraw();
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
index aba6745..e4202fc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
@@ -65,6 +65,7 @@
   private Timer resizeTimer;
   private int editAreaHeight;
   private boolean autoClosed;
+  private CallbackGroup pendingGroup;
 
   @UiField Widget header;
   @UiField Element summary;
@@ -238,7 +239,7 @@
   }
 
   private void restoreSelection() {
-    if (getFromTo() != null) {
+    if (getFromTo() != null && comment.in_reply_to() == null) {
       getCm().setSelection(getFromTo().getFrom(), getFromTo().getTo());
     }
   }
@@ -262,10 +263,17 @@
   @UiHandler("save")
   void onSave(ClickEvent e) {
     e.stopPropagation();
-    save(null);
+    CallbackGroup group = new CallbackGroup();
+    save(group);
+    group.done();
   }
 
   void save(CallbackGroup group) {
+    if (pendingGroup != null) {
+      pendingGroup.addListener(group);
+      return;
+    }
+
     String message = editArea.getValue().trim();
     if (message.length() == 0) {
       return;
@@ -275,10 +283,12 @@
     input.message(message);
     enableEdit(false);
 
+    pendingGroup = group;
     GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
       @Override
       public void onSuccess(CommentInfo result) {
         enableEdit(true);
+        pendingGroup = null;
         set(result);
         setEdit(false);
         if (autoClosed) {
@@ -290,14 +300,14 @@
       @Override
       public void onFailure(Throwable e) {
         enableEdit(true);
+        pendingGroup = null;
         super.onFailure(e);
       }
     };
     if (input.id() == null) {
-      CommentApi.createDraft(psId, input, group == null ? cb : group.add(cb));
+      CommentApi.createDraft(psId, input, group.add(cb));
     } else {
-      CommentApi.updateDraft(
-          psId, input.id(), input, group == null ? cb : group.add(cb));
+      CommentApi.updateDraft(psId, input.id(), input, group.add(cb));
     }
     getCm().focus();
   }
@@ -332,13 +342,15 @@
       restoreSelection();
     } else {
       setEdit(false);
+      pendingGroup = new CallbackGroup();
       CommentApi.deleteDraft(psId, comment.id(),
-          new GerritCallback<JavaScriptObject>() {
+          pendingGroup.addFinal(new GerritCallback<JavaScriptObject>() {
         @Override
         public void onSuccess(JavaScriptObject result) {
+          pendingGroup = null;
           removeUI();
         }
-      });
+      }));
     }
   }
 
@@ -351,7 +363,9 @@
         case 's':
         case 'S':
           e.preventDefault();
-          save(null);
+          CallbackGroup group = new CallbackGroup();
+          save(group);
+          group.done();
           return;
       }
     } else if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE && !isDirty()) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java
index 182c5db..fc95480 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java
@@ -251,7 +251,9 @@
 
     removeKeyHandlerRegistrations();
     if (commentManager != null) {
-      commentManager.saveAllDrafts(null);
+      CallbackGroup group = new CallbackGroup();
+      commentManager.saveAllDrafts(group);
+      group.done();
     }
     if (resizeHandler != null) {
       resizeHandler.removeHandler();
@@ -694,7 +696,8 @@
       public void run() {
         CallbackGroup group = new CallbackGroup();
         commentManager.saveAllDrafts(group);
-        group.addFinal(new GerritCallback<Void>() {
+        group.done();
+        group.addListener(new GerritCallback<Void>() {
           @Override
           public void onSuccess(Void result) {
             String b = base != null ? String.valueOf(base.get()) : null;
@@ -703,7 +706,7 @@
               PageLinks.toChange(changeId, rev),
               new ChangeScreen2(changeId, b, rev, openReplyBox));
           }
-        }).onSuccess(null);
+        });
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
index 073c949..51a899d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
@@ -82,6 +82,18 @@
     applyAllSuccess();
   }
 
+  public void addListener(AsyncCallback<Void> cb) {
+    if (!failed && finalAdded && remaining.isEmpty()) {
+      cb.onSuccess(null);
+    } else {
+      handleAdd(cb).onSuccess(null);
+    }
+  }
+
+  public void addListener(CallbackGroup group) {
+    addListener(group.add(CallbackGroup.<Void> emptyCallback()));
+  }
+
   private void applyAllSuccess() {
     if (!failed && finalAdded && remaining.isEmpty()) {
       for (CallbackImpl<?> cb : callbacks) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
index 6bd5429..7afded5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.client.RpcStatus;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.Scheduler;
 import com.google.gwt.http.client.Request;
 import com.google.gwt.http.client.RequestBuilder;
 import com.google.gwt.http.client.RequestBuilder.Method;
@@ -124,7 +125,8 @@
         }
 
       } else if (200 <= status && status < 300) {
-        T data;
+        long start = System.currentTimeMillis();
+        final T data;
         if (isTextBody(res)) {
           data = NativeString.wrap(res.getText()).cast();
         } else if (isJsonBody(res)) {
@@ -149,14 +151,25 @@
           return;
         }
 
-        try {
-          cb.onSuccess(data);
-        } finally {
-          if (!background) {
-            RpcStatus.INSTANCE.onRpcComplete();
+        Scheduler.ScheduledCommand cmd = new Scheduler.ScheduledCommand() {
+          @Override
+          public void execute() {
+            try {
+              cb.onSuccess(data);
+            } finally {
+              if (!background) {
+                RpcStatus.INSTANCE.onRpcComplete();
+              }
+            }
           }
-        }
+        };
 
+        // Defer handling the response if the parse took a while.
+        if ((System.currentTimeMillis() - start) > 75) {
+          Scheduler.get().scheduleDeferred(cmd);
+        } else {
+          cmd.execute();
+        }
       } else {
         String msg;
         if (isTextBody(res)) {
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 b92d1cb..73b1be3 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
@@ -234,7 +234,7 @@
     Term id = QueryBuilder.idTerm(cd);
     Document doc = toDocument(cd);
     try {
-      if (cd.getChange().getStatus().isOpen()) {
+      if (cd.change().getStatus().isOpen()) {
         Futures.allAsList(
             closedIndex.delete(id),
             openIndex.insert(doc)).get();
@@ -243,9 +243,7 @@
             openIndex.delete(id),
             closedIndex.insert(doc)).get();
       }
-    } catch (ExecutionException e) {
-      throw new IOException(e);
-    } catch (InterruptedException e) {
+    } catch (OrmException | ExecutionException | InterruptedException e) {
       throw new IOException(e);
     }
   }
@@ -256,7 +254,7 @@
     Term id = QueryBuilder.idTerm(cd);
     Document doc = toDocument(cd);
     try {
-      if (cd.getChange().getStatus().isOpen()) {
+      if (cd.change().getStatus().isOpen()) {
         Futures.allAsList(
             closedIndex.delete(id),
             openIndex.replace(id, doc)).get();
@@ -265,9 +263,7 @@
             openIndex.delete(id),
             closedIndex.replace(id, doc)).get();
       }
-    } catch (ExecutionException e) {
-      throw new IOException(e);
-    } catch (InterruptedException e) {
+    } catch (OrmException | ExecutionException | InterruptedException e) {
       throw new IOException(e);
     }
   }
@@ -280,9 +276,7 @@
       Futures.allAsList(
           openIndex.delete(id),
           closedIndex.delete(id)).get();
-    } catch (ExecutionException e) {
-      throw new IOException(e);
-    } catch (InterruptedException e) {
+    } catch (ExecutionException | InterruptedException e) {
       throw new IOException(e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index 3b3dbb8..356b7d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -35,11 +35,11 @@
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.SystemException;
 
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
@@ -52,10 +52,19 @@
   public static final StoredValue<PatchSet> PATCH_SET = create(PatchSet.class);
   public static final StoredValue<ChangeControl> CHANGE_CONTROL = create(ChangeControl.class);
 
+  public static Change getChange(Prolog engine) throws SystemException {
+    ChangeData cd = CHANGE_DATA.get(engine);
+    try {
+      return cd.change();
+    } catch (OrmException e) {
+      throw new SystemException("Cannot load change " + cd.getId());
+    }
+  }
+
   public static final StoredValue<PatchSetInfo> PATCH_SET_INFO = new StoredValue<PatchSetInfo>() {
     @Override
     public PatchSetInfo createValue(Prolog engine) {
-      Change change = StoredValues.CHANGE_DATA.get(engine).getChange();
+      Change change = getChange(engine);
       PatchSet ps = StoredValues.PATCH_SET.get(engine);
       PrologEnvironment env = (PrologEnvironment) engine.control;
       PatchSetInfoFactory patchInfoFactory =
@@ -74,7 +83,7 @@
       PrologEnvironment env = (PrologEnvironment) engine.control;
       PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
       PatchListCache plCache = env.getArgs().getPatchListCache();
-      Change change = StoredValues.CHANGE_DATA.get(engine).getChange();
+      Change change = getChange(engine);
       Project.NameKey projectKey = change.getProject();
       ObjectId a = null;
       ObjectId b = ObjectId.fromString(psInfo.getRevId());
@@ -95,13 +104,11 @@
     public Repository createValue(Prolog engine) {
       PrologEnvironment env = (PrologEnvironment) engine.control;
       GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
-      Change change = StoredValues.CHANGE_DATA.get(engine).getChange();
+      Change change = getChange(engine);
       Project.NameKey projectKey = change.getProject();
       final Repository repo;
       try {
         repo = gitMgr.openRepository(projectKey);
-      } catch (RepositoryNotFoundException e) {
-        throw new SystemException(e.getMessage());
       } catch (IOException e) {
         throw new SystemException(e.getMessage());
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 7b7476f..25654d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -19,10 +19,10 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
@@ -36,6 +36,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeKind;
+import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.util.TimeUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -77,21 +78,18 @@
   public ApprovalsUtil() {
   }
 
-  public static enum ReviewerState {
-    REVIEWER, CC;
-  }
-
   /**
    * Get all reviewers for a change.
    *
    * @param db review database.
    * @param changeId change ID.
    * @return multimap of reviewers keyed by state, where each account appears
-   *     exactly once in {@link SetMultimap#values()}.
+   *     exactly once in {@link SetMultimap#values()}, and
+   *     {@link ReviewerState#REMOVED} is not present.
    * @throws OrmException if reviewers for the change could not be read.
    */
-  public SetMultimap<ReviewerState, Account.Id> getReviewers(ReviewDb db,
-      Change.Id changeId) throws OrmException {
+  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
+      ReviewDb db, Change.Id changeId) throws OrmException {
     return getReviewers(db.patchSetApprovals().byChange(changeId));
   }
 
@@ -101,9 +99,10 @@
    * @param allApprovals all approvals to consider; must all belong to the same
    *     change.
    * @return multimap of reviewers keyed by state, where each account appears
-   *     exactly once in {@link SetMultimap#values()}.
+   *     exactly once in {@link SetMultimap#values()}, and
+   *     {@link ReviewerState#REMOVED} is not present.
    */
-  public static SetMultimap<ReviewerState, Account.Id> getReviewers(
+  public static ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
       Iterable<PatchSetApproval> allApprovals) {
     PatchSetApproval first = null;
     SetMultimap<ReviewerState, Account.Id> reviewers =
@@ -125,7 +124,7 @@
         reviewers.put(ReviewerState.CC, id);
       }
     }
-    return Multimaps.unmodifiableSetMultimap(reviewers);
+    return ImmutableSetMultimap.copyOf(reviewers);
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index 705bf6b..a1116a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -384,7 +384,7 @@
     }
 
     LabelTypes labelTypes = ctl.getLabelTypes();
-    if (cd.getChange().getStatus().isOpen()) {
+    if (cd.change().getStatus().isOpen()) {
       return labelsForOpenChange(cd, labelTypes, standard, detailed);
     } else {
       return labelsForClosedChange(cd, labelTypes, standard, detailed);
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 99292f3..1840e76 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
@@ -28,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -36,6 +35,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index 43e0975..01e191f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
@@ -97,6 +97,7 @@
   private final Project.NameKey projectName;
   private final Repository db;
   private final CommitBuilder commit;
+  private boolean allowEmpty;
 
   @Inject
   public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
@@ -118,6 +119,10 @@
         getCommitBuilder().getCommitter().getTimeZone()));
   }
 
+  public void setAllowEmpty(boolean allowEmpty) {
+    this.allowEmpty = allowEmpty;
+  }
+
   /** Close the cached Repository handle. */
   public void close() {
     getRepository().close();
@@ -131,6 +136,10 @@
     return db;
   }
 
+  boolean allowEmpty() {
+    return allowEmpty;
+  }
+
   public CommitBuilder getCommitBuilder() {
     return commit;
   }
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 a9710e1..59431b5 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
@@ -18,7 +18,6 @@
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
-
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
@@ -64,7 +63,6 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -90,6 +88,7 @@
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.mail.MergedSender;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index 0719c7d..4cee593 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -213,7 +213,7 @@
         doSave(config, commit);
 
         final ObjectId res = newTree.writeTree(inserter);
-        if (res.equals(srcTree)) {
+        if (res.equals(srcTree) && !update.allowEmpty()) {
           // If there are no changes to the content, don't create the commit.
           return;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
index 47baa9e..09f9e42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
@@ -25,9 +25,9 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.StarredChange;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.ProjectWatch.Watchers;
+import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
index 801e599..ac367ef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.revwalk.FooterKey;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
new file mode 100644
index 0000000..60d6f56
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -0,0 +1,45 @@
+// 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.notedb;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+public class ChangeNoteUtil {
+  static final String GERRIT_PLACEHOLDER_HOST = "gerrit";
+
+  static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-Set");
+
+  public static String changeRefName(Change.Id id) {
+    StringBuilder r = new StringBuilder();
+    r.append(RefNames.REFS_CHANGES);
+    int n = id.get();
+    int m = n % 100;
+    if (m < 10) {
+      r.append('0');
+    }
+    r.append(m);
+    r.append('/');
+    r.append(n);
+    r.append("/meta");
+    return r.toString();
+  }
+
+  private ChangeNoteUtil() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
new file mode 100644
index 0000000..667e3f0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -0,0 +1,309 @@
+// 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.notedb;
+
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetApproval.LabelId;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** View of a single {@link Change} based on the log of its notes branch. */
+public class ChangeNotes extends VersionedMetaData {
+  private static final Ordering<PatchSetApproval> PSA_BY_TIME =
+      Ordering.natural().onResultOf(
+        new Function<PatchSetApproval, Timestamp>() {
+          @Override
+          public Timestamp apply(PatchSetApproval input) {
+            return input.getGranted();
+          }
+        });
+
+  @Singleton
+  public static class Factory {
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    Factory(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    public ChangeNotes create(Change change) {
+      return new ChangeNotes(repoManager, change);
+    }
+  }
+
+  private static class Parser {
+    private final Change.Id changeId;
+    private final ObjectId tip;
+    private final RevWalk walk;
+    private final ListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+    private final Map<Account.Id, ReviewerState> reviewers;
+
+    private Parser(Change.Id changeId, ObjectId tip, RevWalk walk) {
+      this.changeId = changeId;
+      this.tip = tip;
+      this.walk = walk;
+      approvals = ArrayListMultimap.create();
+      reviewers = Maps.newLinkedHashMap();
+    }
+
+    private void parseAll() throws ConfigInvalidException, IOException {
+      walk.markStart(walk.parseCommit(tip));
+      for (RevCommit commit : walk) {
+        parse(commit);
+      }
+      pruneReviewers();
+      for (Collection<PatchSetApproval> v : approvals.asMap().values()) {
+        Collections.sort((List<PatchSetApproval>) v, PSA_BY_TIME);
+      }
+    }
+
+    private void parse(RevCommit commit) throws ConfigInvalidException {
+      PatchSet.Id psId = parsePatchSetId(commit);
+      Account.Id accountId = parseIdent(commit);
+      List<PatchSetApproval> psas = approvals.get(psId);
+
+      Map<String, PatchSetApproval> curr =
+          Maps.newHashMapWithExpectedSize(psas.size());
+      for (PatchSetApproval psa : psas) {
+        if (psa.getAccountId().equals(accountId)) {
+          curr.put(psa.getLabel(), psa);
+        }
+      }
+
+      for (String line : commit.getFooterLines(FOOTER_LABEL)) {
+        PatchSetApproval psa = parseApproval(psId, accountId, commit, line);
+        if (!curr.containsKey(psa.getLabel())) {
+          curr.put(psa.getLabel(), psa);
+          psas.add(psa);
+        }
+      }
+      for (ReviewerState state : ReviewerState.values()) {
+        for (String line : commit.getFooterLines(state.getFooterKey())) {
+          parseReviewer(state, line);
+        }
+      }
+    }
+
+    private PatchSet.Id parsePatchSetId(RevCommit commit)
+        throws ConfigInvalidException {
+      List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
+      if (psIdLines.size() != 1) {
+        throw parseException("missing or multiple %s: %s",
+            FOOTER_PATCH_SET, psIdLines);
+      }
+      Integer psId = Ints.tryParse(psIdLines.get(0));
+      if (psId == null) {
+        throw parseException("invalid %s: %s",
+            FOOTER_PATCH_SET, psIdLines.get(0));
+      }
+      return new PatchSet.Id(changeId, psId);
+    }
+
+    private PatchSetApproval parseApproval(PatchSet.Id psId, Account.Id accountId,
+        RevCommit commit, String line) throws ConfigInvalidException {
+      try {
+        LabelVote l = LabelVote.parseWithEquals(line);
+        return new PatchSetApproval(
+            new PatchSetApproval.Key(
+              psId, parseIdent(commit), new LabelId(l.getLabel())),
+            l.getValue(),
+            new Timestamp(commit.getCommitterIdent().getWhen().getTime()));
+      } catch (IllegalArgumentException e) {
+        ConfigInvalidException pe =
+            parseException("invalid %s: %s", FOOTER_LABEL, line);
+        pe.initCause(e);
+        throw pe;
+      }
+    }
+
+    private Account.Id parseIdent(RevCommit commit)
+        throws ConfigInvalidException {
+      return parseIdent(commit.getCommitterIdent());
+    }
+
+    private Account.Id parseIdent(PersonIdent ident)
+        throws ConfigInvalidException {
+      String email = ident.getEmailAddress();
+      int at = email.indexOf('@');
+      if (at >= 0) {
+        String host = email.substring(at + 1, email.length());
+        Integer id = Ints.tryParse(email.substring(0, at));
+        if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) {
+          return new Account.Id(id);
+        }
+      }
+      throw parseException("invalid identity, expected <id>@%s: %s",
+        GERRIT_PLACEHOLDER_HOST, email);
+    }
+
+    private void parseReviewer(ReviewerState state, String line)
+        throws ConfigInvalidException {
+      PersonIdent ident = RawParseUtils.parsePersonIdent(line);
+      if (ident == null) {
+        throw parseException(
+            "invalid %s: %s", state.getFooterKey().getName(), line);
+      }
+      Account.Id accountId = parseIdent(ident);
+      if (!reviewers.containsKey(accountId)) {
+        reviewers.put(accountId, state);
+      }
+    }
+
+    private void pruneReviewers() {
+      Set<Account.Id> removed = Sets.newHashSetWithExpectedSize(reviewers.size());
+      Iterator<Map.Entry<Account.Id, ReviewerState>> rit =
+          reviewers.entrySet().iterator();
+      while (rit.hasNext()) {
+        Map.Entry<Account.Id, ReviewerState> e = rit.next();
+        if (e.getValue() == ReviewerState.REMOVED) {
+          removed.add(e.getKey());
+          rit.remove();
+        }
+      }
+
+      Iterator<Map.Entry<PatchSet.Id, PatchSetApproval>> ait =
+          approvals.entries().iterator();
+      while (ait.hasNext()) {
+        if (removed.contains(ait.next().getValue().getAccountId())) {
+          ait.remove();
+        }
+      }
+    }
+
+    private ConfigInvalidException parseException(String fmt, Object... args) {
+      return new ConfigInvalidException("Change " + changeId + ": "
+          + String.format(fmt, args));
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final Change change;
+  private boolean loaded;
+  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+  private ImmutableSetMultimap<ReviewerState, Account.Id> reviewers;
+
+  @VisibleForTesting
+  ChangeNotes(GitRepositoryManager repoManager, Change change) {
+    this.repoManager = repoManager;
+    this.change = change;
+  }
+
+  // TODO(dborowitz): Wrap fewer exceptions if/when we kill gwtorm.
+  public ChangeNotes load() throws OrmException {
+    if (!loaded) {
+      Repository repo;
+      try {
+        repo = repoManager.openRepository(change.getProject());
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+      try {
+        load(repo);
+        loaded = true;
+      } catch (ConfigInvalidException | IOException e) {
+        throw new OrmException(e);
+      } finally {
+        repo.close();
+      }
+    }
+    return this;
+  }
+
+  public Change getChange() {
+    return change;
+  }
+
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+    return approvals;
+  }
+
+  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers() {
+    return reviewers;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ChangeNoteUtil.changeRefName(change.getId());
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    ObjectId rev = getRevision();
+    if (rev == null) {
+      return;
+    }
+    RevWalk walk = new RevWalk(reader);
+    try {
+      Parser parser = new Parser(change.getId(), rev, walk);
+      parser.parseAll();
+      approvals = ImmutableListMultimap.copyOf(parser.approvals);
+      ImmutableSetMultimap.Builder<ReviewerState, Account.Id> reviewers =
+          ImmutableSetMultimap.builder();
+      for (Map.Entry<Account.Id, ReviewerState> e
+          : parser.reviewers.entrySet()) {
+        reviewers.put(e.getValue(), e.getKey());
+      }
+      this.reviewers = reviewers.build();
+    } finally {
+      walk.release();
+    }
+  }
+
+  @Override
+  protected void onSave(CommitBuilder commit) {
+    throw new UnsupportedOperationException(
+        getClass().getSimpleName() + " is read-only");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
new file mode 100644
index 0000000..ae7beac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -0,0 +1,252 @@
+// 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.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+import java.io.IOException;
+import java.util.Date;
+import java.util.Map;
+import java.util.TimeZone;
+
+/**
+ * A single delta to apply atomically to a change.
+ * <p>
+ * This delta becomes a single commit on the notes branch, so there are
+ * limitations on the set of modifications that can be handled in a single
+ * update. In particular, there is a single author and timestamp for each
+ * update.
+ * <p>
+ * This class is not thread-safe.
+ */
+public class ChangeUpdate extends VersionedMetaData {
+  @Singleton
+  public static class Factory {
+    private final GitRepositoryManager repoManager;
+    private final AccountCache accountCache;
+    private final MetaDataUpdate.User updateFactory;
+    private final ProjectCache projectCache;
+    private final Provider<IdentifiedUser> user;
+    private final PersonIdent serverIdent;
+
+    @Inject
+    Factory(
+        GitRepositoryManager repoManager,
+        AccountCache accountCache,
+        MetaDataUpdate.User updateFactory,
+        ProjectCache projectCache,
+        Provider<IdentifiedUser> user,
+        @GerritPersonIdent PersonIdent serverIdent) {
+      this.repoManager = repoManager;
+      this.accountCache = accountCache;
+      this.updateFactory = updateFactory;
+      this.projectCache = projectCache;
+      this.user = user;
+      this.serverIdent = serverIdent;
+    }
+
+    public ChangeUpdate create(Change change) {
+      return create(change, serverIdent.getWhen());
+    }
+
+    public ChangeUpdate create(Change change, Date when) {
+      return new ChangeUpdate(
+          repoManager, accountCache, updateFactory,
+          projectCache.get(change.getProject()).getLabelTypes(),
+          change, user.get().getAccount(), when, serverIdent.getTimeZone());
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AccountCache accountCache;
+  private final MetaDataUpdate.User updateFactory;
+  private final LabelTypes labelTypes;
+  private final Change change;
+  private final Account account;
+  private final Date when;
+  private final TimeZone tz;
+  private final Map<String, Short> approvals;
+  private final Map<Account.Id, ReviewerState> reviewers;
+  private String subject;
+  private PatchSet.Id psId;
+
+  @VisibleForTesting
+  ChangeUpdate(GitRepositoryManager repoManager, AccountCache accountCache,
+      LabelTypes labelTypes, Change change, Account account, Date when,
+      TimeZone tz) {
+    this(repoManager, accountCache, null, labelTypes, change, account, when,
+        tz);
+  }
+
+  private ChangeUpdate(GitRepositoryManager repoManager,
+      AccountCache accountCache, @Nullable MetaDataUpdate.User updateFactory,
+      LabelTypes labelTypes, Change change, Account account, Date when,
+      TimeZone tz) {
+    this.repoManager = repoManager;
+    this.accountCache = accountCache;
+    this.updateFactory = updateFactory;
+    this.labelTypes = labelTypes;
+    this.change = change;
+    this.account = account;
+    this.when = when;
+    this.tz = tz;
+    this.approvals = Maps.newTreeMap(labelTypes.nameComparator());
+    this.reviewers = Maps.newLinkedHashMap();
+  }
+
+  public Account getAccount() {
+    return account;
+  }
+
+  public Date getWhen() {
+    return when;
+  }
+
+  public void putApproval(String label, short value) {
+    approvals.put(label, value);
+  }
+
+  public void setSubject(String subject) {
+    this.subject = subject;
+  }
+
+  public void setPatchSetId(PatchSet.Id psId) {
+    checkArgument(psId == null || psId.getParentKey().equals(change.getKey()));
+    this.psId = psId;
+  }
+
+  public void putReviewer(Account.Id reviewer, ReviewerState type) {
+    checkArgument(type != ReviewerState.REMOVED, "invalid ReviewerType");
+    reviewers.put(reviewer, type);
+  }
+
+  public void removeReviewer(Account.Id reviewer) {
+    reviewers.put(reviewer, ReviewerState.REMOVED);
+  }
+
+  public RevCommit commit() throws IOException {
+    return commit(checkNotNull(updateFactory, "MetaDataUpdate.Factory")
+        .create(change.getProject()));
+  }
+
+  @Override
+  public RevCommit commit(MetaDataUpdate md) throws IOException {
+    Repository repo = repoManager.openRepository(change.getProject());
+    try {
+      load(repo);
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    } finally {
+      repo.close();
+    }
+
+    md.setAllowEmpty(true);
+    CommitBuilder cb = md.getCommitBuilder();
+    cb.setCommitter(newCommitter());
+    return super.commit(md);
+  }
+
+  public PersonIdent newIdent(Account author) {
+    return new PersonIdent(
+        author.getFullName(),
+        author.getId().get() + "@" + GERRIT_PLACEHOLDER_HOST,
+        when, tz);
+  }
+
+  public PersonIdent newCommitter() {
+    return newIdent(account);
+  }
+
+  @Override
+  protected String getRefName() {
+    return ChangeNoteUtil.changeRefName(change.getId());
+  }
+
+  @Override
+  protected void onSave(CommitBuilder commit) {
+    if (approvals.isEmpty() && reviewers.isEmpty()) {
+      return;
+    }
+    int ps = psId != null ? psId.get() : change.currentPatchSetId().get();
+    StringBuilder msg = new StringBuilder();
+    if (subject != null) {
+      msg.append(subject);
+    } else {
+      msg.append("Update patch set ").append(ps);
+    }
+    msg.append("\n\n");
+    addFooter(msg, FOOTER_PATCH_SET, ps);
+    for (Map.Entry<Account.Id, ReviewerState> e : reviewers.entrySet()) {
+      Account account = accountCache.get(e.getKey()).getAccount();
+      PersonIdent ident = newIdent(account);
+      addFooter(msg, e.getValue().getFooterKey())
+          .append(ident.getName())
+          .append(" <").append(ident.getEmailAddress()).append(">\n");
+    }
+    for (Map.Entry<String, Short> e : approvals.entrySet()) {
+      LabelType lt = labelTypes.byLabel(e.getKey());
+      if (lt != null) {
+        addFooter(msg, FOOTER_LABEL,
+            new LabelVote(lt.getName(), e.getValue()).formatWithEquals());
+      }
+    }
+    commit.setMessage(msg.toString());
+  }
+
+  private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
+    return sb.append(footer.getName()).append(": ");
+  }
+
+  private static void addFooter(StringBuilder sb, FooterKey footer,
+      Object value) {
+    addFooter(sb, footer).append(value).append('\n');
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    // Do nothing; just reads current revision.
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
new file mode 100644
index 0000000..b829a69
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
@@ -0,0 +1,39 @@
+// 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.notedb;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/** State of a reviewer on a change. */
+public enum ReviewerState {
+  /** The user has contributed at least one nonzero vote on the change. */
+  REVIEWER(new FooterKey("Reviewer")),
+
+  /** The reviewer was added to the change, but has not voted. */
+  CC(new FooterKey("CC")),
+
+  /** The user was previously a reviewer on the change, but was removed. */
+  REMOVED(new FooterKey("Removed"));
+
+  private final FooterKey footerKey;
+
+  private ReviewerState(FooterKey footerKey) {
+    this.footerKey = footerKey;
+  }
+
+  FooterKey getFooterKey() {
+    return footerKey;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index f73a2f7..4c2a30b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -171,6 +172,7 @@
 
   interface AssistedFactory {
     ChangeControl create(RefControl refControl, Change change);
+    ChangeControl create(RefControl refControl, ChangeNotes notes);
   }
 
   /**
@@ -189,23 +191,34 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeData.Factory changeDataFactory;
   private final RefControl refControl;
-  private final Change change;
+  private final ChangeNotes notes;
+
+  @AssistedInject
+  ChangeControl(
+      ApprovalsUtil approvalsUtil,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
+      @Assisted RefControl refControl,
+      @Assisted Change change) {
+    this(approvalsUtil, changeDataFactory, refControl,
+        notesFactory.create(change));
+  }
 
   @AssistedInject
   ChangeControl(
       ApprovalsUtil approvalsUtil,
       ChangeData.Factory changeDataFactory,
       @Assisted RefControl refControl,
-      @Assisted Change change) {
+      @Assisted ChangeNotes notes) {
     this.approvalsUtil = approvalsUtil;
     this.changeDataFactory = changeDataFactory;
     this.refControl = refControl;
-    this.change = change;
+    this.notes = notes;
   }
 
   public ChangeControl forUser(final CurrentUser who) {
     return new ChangeControl(approvalsUtil, changeDataFactory,
-        getRefControl().forUser(who), change);
+        getRefControl().forUser(who), notes);
   }
 
   public RefControl getRefControl() {
@@ -225,12 +238,17 @@
   }
 
   public Change getChange() {
-    return change;
+    return notes.getChange();
+  }
+
+  public ChangeNotes getNotes() {
+    return notes;
   }
 
   /** Can this user see this change? */
   public boolean isVisible(ReviewDb db) throws OrmException {
-    if (change.getStatus() == Change.Status.DRAFT && !isDraftVisible(db, null)) {
+    if (getChange().getStatus() == Change.Status.DRAFT
+        && !isDraftVisible(db, null)) {
       return false;
     }
     return isRefVisible();
@@ -326,7 +344,7 @@
   public boolean isOwner() {
     if (getCurrentUser().isIdentifiedUser()) {
       final IdentifiedUser i = (IdentifiedUser) getCurrentUser();
-      return i.getAccountId().equals(change.getOwner());
+      return i.getAccountId().equals(getChange().getOwner());
     }
     return false;
   }
@@ -384,7 +402,7 @@
 
   /** Can this user edit the topic name? */
   public boolean canEditTopicName() {
-    if (change.getStatus().isOpen()) {
+    if (getChange().getStatus().isOpen()) {
       return isOwner() // owner (aka creator) of the change can edit topic
           || getRefControl().isOwner() // branch owner can edit topic
           || getProjectControl().isOwner() // project owner can edit topic
@@ -411,18 +429,18 @@
   public List<SubmitRecord> canSubmit(ReviewDb db, PatchSet patchSet,
       @Nullable ChangeData cd, boolean fastEvalLabels, boolean allowClosed,
       boolean allowDraft) {
-    if (!allowClosed && change.getStatus().isClosed()) {
+    if (!allowClosed && getChange().getStatus().isClosed()) {
       SubmitRecord rec = new SubmitRecord();
       rec.status = SubmitRecord.Status.CLOSED;
       return Collections.singletonList(rec);
     }
 
-    if (!patchSet.getId().equals(change.currentPatchSetId())) {
+    if (!patchSet.getId().equals(getChange().currentPatchSetId())) {
       return ruleError("Patch set " + patchSet.getPatchSetId() + " is not current");
     }
 
     cd = changeData(db, cd);
-    if ((change.getStatus() == Change.Status.DRAFT || patchSet.isDraft())
+    if ((getChange().getStatus() == Change.Status.DRAFT || patchSet.isDraft())
         && !allowDraft) {
       return cannotSubmitDraft(db, patchSet, cd);
     }
@@ -432,7 +450,7 @@
     try {
       evaluator = new SubmitRuleEvaluator(db, patchSet,
           getProjectControl(),
-          this, change, cd,
+          this, getChange(), cd,
           fastEvalLabels,
           "locate_submit_rule", "can_submit",
           "locate_submit_filter", "filter_submit_results");
@@ -447,7 +465,7 @@
       // required for this change to be submittable. Each label will indicate
       // whether or not that is actually possible given the permissions.
       log.error("Submit rule '" + evaluator.getSubmitRule() + "' for change "
-          + change.getId() + " of " + getProject().getName()
+          + getChange().getId() + " of " + getProject().getName()
           + " has no solution.");
       return ruleError("Project submit rule has no solution");
     }
@@ -569,7 +587,8 @@
       @Nullable ChangeData cd) {
     cd = changeData(db, cd);
     try {
-      if (change.getStatus() == Change.Status.DRAFT && !isDraftVisible(db, cd)) {
+      if (getChange().getStatus() == Change.Status.DRAFT
+          && !isDraftVisible(db, cd)) {
         return typeRuleError("Patch set " + patchSet.getPatchSetId()
             + " not found");
       }
@@ -586,7 +605,7 @@
     SubmitRuleEvaluator evaluator;
     try {
       evaluator = new SubmitRuleEvaluator(db, patchSet,
-          getProjectControl(), this, change, cd,
+          getProjectControl(), this, getChange(), cd,
           false,
           "locate_submit_type", "get_submit_type",
           "locate_submit_type_filter", "filter_submit_type_results");
@@ -598,7 +617,7 @@
     if (results.isEmpty()) {
       // Should never occur for a well written rule
       log.error("Submit rule '" + evaluator.getSubmitRule() + "' for change "
-          + change.getId() + " of " + getProject().getName()
+          + getChange().getId() + " of " + getProject().getName()
           + " has no solution.");
       return typeRuleError("Project submit rule has no solution");
     }
@@ -606,7 +625,7 @@
     Term typeTerm = results.get(0);
     if (!typeTerm.isSymbol()) {
       log.error("Submit rule '" + evaluator.getSubmitRule() + "' for change "
-          + change.getId() + " of " + getProject().getName()
+          + getChange().getId() + " of " + getProject().getName()
           + " did not return a symbol.");
       return typeRuleError("Project submit rule has invalid solution");
     }
@@ -621,7 +640,7 @@
   }
 
   private List<SubmitRecord> logInvalidResult(Term rule, Term record, String reason) {
-    return logRuleError("Submit rule " + rule + " for change " + change.getId()
+    return logRuleError("Submit rule " + rule + " for change " + getChange().getId()
         + " of " + getProject().getName() + " output invalid result: " + record
         + (reason == null ? "" : ". Reason: " + reason));
   }
@@ -649,7 +668,7 @@
 
   private SubmitTypeRecord logInvalidType(Term rule, String record) {
     return logTypeRuleError("Submit type rule " + rule + " for change "
-        + change.getId() + " of " + getProject().getName()
+        + getChange().getId() + " of " + getProject().getName()
         + " output invalid result: " + record);
   }
 
@@ -671,7 +690,7 @@
   }
 
   private ChangeData changeData(ReviewDb db, @Nullable ChangeData cd) {
-    return cd != null ? cd : changeDataFactory.create(db, change);
+    return cd != null ? cd : changeDataFactory.create(db, getChange());
   }
 
   private void appliedBy(SubmitRecord.Label label, Term status)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 8f8cb70..f228e79f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -34,9 +34,9 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -133,6 +133,18 @@
     ChangeData create(ReviewDb db, ChangeControl c);
   }
 
+  /**
+   * Create an instance for testing only.
+   * <p>
+   * Attempting to lazy load data will fail with NPEs.
+   *
+   * @param id change ID
+   * @return instance for testing.
+   */
+  static ChangeData createForTest(Change.Id id) {
+    return new ChangeData(null, null, null, id);
+  }
+
   private final ReviewDb db;
   private final GitRepositoryManager repoManager;
   private final PatchListCache patchListCache;
@@ -157,7 +169,7 @@
   private boolean patchesLoaded;
 
   @AssistedInject
-  public ChangeData(
+  private ChangeData(
       GitRepositoryManager repoManager,
       PatchListCache patchListCache,
       @Assisted ReviewDb db,
@@ -169,7 +181,7 @@
   }
 
   @AssistedInject
-  ChangeData(
+  private ChangeData(
       GitRepositoryManager repoManager,
       PatchListCache patchListCache,
       @Assisted ReviewDb db,
@@ -182,7 +194,7 @@
   }
 
   @AssistedInject
-  ChangeData(
+  private ChangeData(
       GitRepositoryManager repoManager,
       PatchListCache patchListCache,
       @Assisted ReviewDb db,
@@ -295,14 +307,6 @@
     return legacyId;
   }
 
-  public Change getChange() {
-    return change;
-  }
-
-  public boolean hasChange() {
-    return change != null;
-  }
-
   boolean fastIsVisibleTo(CurrentUser user) {
     return visibleTo == user;
   }
@@ -323,10 +327,6 @@
     return change;
   }
 
-  void setChange(Change c) {
-    change = c;
-  }
-
   public PatchSet currentPatchSet() throws OrmException {
     if (currentPatchSet == null) {
       Change c = change();
@@ -385,7 +385,7 @@
       IncorrectObjectTypeException {
     PatchSet.Id psId = change().currentPatchSetId();
     String sha1 = db.patchSets().get(psId).getRevision().get();
-    Repository repo = repoManager.openRepository(change.getProject());
+    Repository repo = repoManager.openRepository(change().getProject());
     try {
       RevWalk walk = new RevWalk(repo);
       try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
index 65f71ae..00d8ce3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
@@ -308,8 +308,8 @@
           }
 
           LabelTypes labelTypes = cc.getLabelTypes();
-          c = eventFactory.asChangeAttribute(d.getChange());
-          eventFactory.extend(c, d.getChange());
+          c = eventFactory.asChangeAttribute(d.change());
+          eventFactory.extend(c, d.change());
 
           if (!trackingFooters.isEmpty()) {
             eventFactory.addTrackingIds(c,
@@ -317,11 +317,11 @@
           }
 
           if (includeAllReviewers) {
-            eventFactory.addAllReviewers(c, d.getChange());
+            eventFactory.addAllReviewers(c, d.change());
           }
 
           if (includeSubmitRecords) {
-            PatchSet.Id psId = d.getChange().currentPatchSetId();
+            PatchSet.Id psId = d.change().currentPatchSetId();
             PatchSet patchSet = db.get().patchSets().get(psId);
             List<SubmitRecord> submitResult = cc.canSubmit( //
                 db.get(), patchSet, null, false, true, true);
@@ -368,7 +368,7 @@
           }
 
           if (includeDependencies) {
-            eventFactory.addDependencies(c, d.getChange());
+            eventFactory.addDependencies(c, d.change());
           }
 
           show(c);
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
index 67c2c93..b835b34 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
@@ -15,7 +15,6 @@
 package gerrit;
 
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.rules.StoredValues;
 
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -36,8 +35,7 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Change change = StoredValues.CHANGE_DATA.get(engine).getChange();
-    Branch.NameKey name = change.getDest();
+    Branch.NameKey name = StoredValues.getChange(engine).getDest();
 
     if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
       return engine.fail();
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
index efe1c82..51502f8 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
@@ -15,7 +15,6 @@
 package gerrit;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.rules.StoredValues;
 
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -40,8 +39,7 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Change change = StoredValues.CHANGE_DATA.get(engine).getChange();
-    Account.Id ownerId = change.getOwner();
+    Account.Id ownerId = StoredValues.getChange(engine).getOwner();
 
     if (!a1.unify(new StructureTerm(user, new IntegerTerm(ownerId.get())), engine.trail)) {
       return engine.fail();
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
index 9a1d8b6..29c6704 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
@@ -14,7 +14,6 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.rules.StoredValues;
 
@@ -36,8 +35,7 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Change change = StoredValues.CHANGE_DATA.get(engine).getChange();
-    Project.NameKey name = change.getProject();
+    Project.NameKey name = StoredValues.getChange(engine).getProject();
 
     if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
       return engine.fail();
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
index e6748de..7ba648f 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
@@ -36,7 +36,7 @@
     Term a1 = arg1.dereference();
 
     Term topicTerm = Prolog.Nil;
-    Change change = StoredValues.CHANGE_DATA.get(engine).getChange();
+    Change change = StoredValues.getChange(engine);
     String topic = change.getTopic();
     if (topic != null) {
       topicTerm = SymbolTerm.create(topic);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
new file mode 100644
index 0000000..01e01bf
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -0,0 +1,410 @@
+// 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.notedb;
+
+import static com.google.gerrit.server.project.Util.category;
+import static com.google.gerrit.server.project.Util.value;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.util.TimeUtil;
+import com.google.gerrit.testutil.FakeAccountCache;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.joda.time.DateTimeUtils;
+import org.joda.time.DateTimeUtils.MillisProvider;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class ChangeNotesTest {
+  private static final TimeZone TZ =
+      TimeZone.getTimeZone("America/Los_Angeles");
+
+  private static final Account CHANGE_OWNER;
+  private static final Account OTHER_ACCOUNT;
+  static {
+    CHANGE_OWNER = new Account(new Account.Id(1), TimeUtil.nowTs());
+    CHANGE_OWNER.setFullName("Change Owner");
+    CHANGE_OWNER.setPreferredEmail("change@owner.com");
+    OTHER_ACCOUNT = new Account(new Account.Id(2), TimeUtil.nowTs());
+    OTHER_ACCOUNT.setFullName("Other Account");
+    OTHER_ACCOUNT.setPreferredEmail("other@account.com");
+  }
+
+  private static final LabelTypes LABEL_TYPES = new LabelTypes(ImmutableList.of(
+      category("Verified",
+        value(1, "Verified"),
+        value(0, "No score"),
+        value(-1, "Fails")),
+      category("Code-Review",
+        value(1, "Looks Good To Me"),
+        value(0, "No score"),
+        value(-1, "Do Not Submit"))));
+
+  private Project.NameKey project;
+  private InMemoryRepositoryManager repoManager;
+  private InMemoryRepository repo;
+  private FakeAccountCache accountCache;
+  private volatile long clockStepMs;
+
+  @Before
+  public void setUp() throws Exception {
+    project = new Project.NameKey("test-project");
+    repoManager = new InMemoryRepositoryManager();
+    repo = repoManager.createRepository(project);
+    accountCache = new FakeAccountCache();
+    accountCache.put(CHANGE_OWNER);
+    accountCache.put(OTHER_ACCOUNT);
+  }
+
+  @Before
+  public void setMillisProvider() {
+    clockStepMs = MILLISECONDS.convert(1, SECONDS);
+    final AtomicLong clockMs = new AtomicLong(
+        MILLISECONDS.convert(ChangeUtil.SORT_KEY_EPOCH_MINS, MINUTES)
+        + MILLISECONDS.convert(60, DAYS));
+
+    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
+      @Override
+      public long getMillis() {
+        return clockMs.getAndAdd(clockStepMs);
+      }
+    });
+  }
+
+  @After
+  public void resetMillisProvider() {
+    DateTimeUtils.setCurrentMillisSystem();
+  }
+
+  @Test
+  public void approvalsCommitFormat() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) -1);
+    update.putApproval("Verified", (short) 1);
+    update.putReviewer(CHANGE_OWNER.getId(), ReviewerState.REVIEWER);
+    update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.CC);
+    commit(update);
+    assertEquals("refs/changes/01/1/meta", update.getRefName());
+
+    RevWalk walk = new RevWalk(repo);
+    try {
+      RevCommit commit = walk.parseCommit(update.getRevision());
+      walk.parseBody(commit);
+      assertEquals("Update patch set 1\n"
+          + "\n"
+          + "Patch-Set: 1\n"
+          + "Reviewer: Change Owner <1@gerrit>\n"
+          + "CC: Other Account <2@gerrit>\n"
+          + "Label: Verified=+1\n"
+          + "Label: Code-Review=-1\n",
+          commit.getFullMessage());
+
+      PersonIdent author = commit.getAuthorIdent();
+      assertEquals("Change Owner", author.getName());
+      assertEquals("change@owner.com", author.getEmailAddress());
+      assertEquals(new Date(c.getCreatedOn().getTime() + 1000),
+          author.getWhen());
+      assertEquals(TimeZone.getTimeZone("GMT-8:00"), author.getTimeZone());
+
+      PersonIdent committer = commit.getCommitterIdent();
+      assertEquals("Change Owner", committer.getName());
+      assertEquals("1@gerrit", committer.getEmailAddress());
+      assertEquals(author.getWhen(), committer.getWhen());
+      assertEquals(author.getTimeZone(), committer.getTimeZone());
+    } finally {
+      walk.release();
+    }
+  }
+
+  @Test
+  public void approvalsOnePatchSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) -1);
+    update.putApproval("Verified", (short) 1);
+    commit(update);
+
+    ChangeNotes notes = newNotes(c);
+    assertEquals(1, notes.getApprovals().keySet().size());
+    List<PatchSetApproval> psas =
+      notes.getApprovals().get(c.currentPatchSetId());
+    assertEquals(2, psas.size());
+
+    assertEquals(c.currentPatchSetId(), psas.get(0).getPatchSetId());
+    assertEquals(1, psas.get(0).getAccountId().get());
+    assertEquals("Verified", psas.get(0).getLabel());
+    assertEquals((short) 1, psas.get(0).getValue());
+    assertEquals(truncate(after(c, 1000)), psas.get(0).getGranted());
+
+    assertEquals(c.currentPatchSetId(), psas.get(1).getPatchSetId());
+    assertEquals(1, psas.get(1).getAccountId().get());
+    assertEquals("Code-Review", psas.get(1).getLabel());
+    assertEquals((short) -1, psas.get(1).getValue());
+    assertEquals(psas.get(0).getGranted(), psas.get(1).getGranted());
+  }
+
+  @Test
+  public void approvalsMultiplePatchSets() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) -1);
+    commit(update);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+
+    incrementPatchSet(c);
+    update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) 1);
+    commit(update);
+    PatchSet.Id ps2 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
+    assertEquals(2, notes.getApprovals().keySet().size());
+
+    PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
+    assertEquals(ps1, psa1.getPatchSetId());
+    assertEquals(1, psa1.getAccountId().get());
+    assertEquals("Code-Review", psa1.getLabel());
+    assertEquals((short) -1, psa1.getValue());
+    assertEquals(truncate(after(c, 1000)), psa1.getGranted());
+
+    PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
+    assertEquals(ps2, psa2.getPatchSetId());
+    assertEquals(1, psa2.getAccountId().get());
+    assertEquals("Code-Review", psa2.getLabel());
+    assertEquals((short) +1, psa2.getValue());
+    assertEquals(truncate(after(c, 2000)), psa2.getGranted());
+  }
+
+  @Test
+  public void approvalsMultipleApprovals() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) -1);
+    commit(update);
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        notes.getApprovals().get(c.currentPatchSetId()));
+    assertEquals("Code-Review", psa.getLabel());
+    assertEquals((short) -1, psa.getValue());
+
+    update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) 1);
+    commit(update);
+
+    notes = newNotes(c);
+    psa = Iterables.getOnlyElement(
+        notes.getApprovals().get(c.currentPatchSetId()));
+    assertEquals("Code-Review", psa.getLabel());
+    assertEquals((short) 1, psa.getValue());
+  }
+
+  @Test
+  public void approvalsMultipleUsers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) -1);
+    commit(update);
+
+    update = newUpdate(c, OTHER_ACCOUNT);
+    update.putApproval("Code-Review", (short) 1);
+    commit(update);
+
+    ChangeNotes notes = newNotes(c);
+    assertEquals(1, notes.getApprovals().keySet().size());
+    List<PatchSetApproval> psas =
+      notes.getApprovals().get(c.currentPatchSetId());
+    assertEquals(2, psas.size());
+
+    assertEquals(c.currentPatchSetId(), psas.get(0).getPatchSetId());
+    assertEquals(1, psas.get(0).getAccountId().get());
+    assertEquals("Code-Review", psas.get(0).getLabel());
+    assertEquals((short) -1, psas.get(0).getValue());
+    assertEquals(truncate(after(c, 1000)), psas.get(0).getGranted());
+
+    assertEquals(c.currentPatchSetId(), psas.get(1).getPatchSetId());
+    assertEquals(2, psas.get(1).getAccountId().get());
+    assertEquals("Code-Review", psas.get(1).getLabel());
+    assertEquals((short) 1, psas.get(1).getValue());
+    assertEquals(truncate(after(c, 2000)), psas.get(1).getGranted());
+  }
+
+  @Test
+  public void multipleReviewers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putReviewer(CHANGE_OWNER.getId(), ReviewerState.REVIEWER);
+    update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.REVIEWER);
+    commit(update);
+
+    ChangeNotes notes = newNotes(c);
+    assertEquals(ImmutableSetMultimap.of(
+          ReviewerState.REVIEWER, new Account.Id(1),
+          ReviewerState.REVIEWER, new Account.Id(2)),
+        notes.getReviewers());
+  }
+
+  @Test
+  public void reviewerTypes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putReviewer(CHANGE_OWNER.getId(), ReviewerState.REVIEWER);
+    update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.CC);
+    commit(update);
+
+    ChangeNotes notes = newNotes(c);
+    assertEquals(ImmutableSetMultimap.of(
+          ReviewerState.REVIEWER, new Account.Id(1),
+          ReviewerState.CC, new Account.Id(2)),
+        notes.getReviewers());
+  }
+
+  @Test
+  public void oneReviewerMultipleTypes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.REVIEWER);
+    commit(update);
+
+    ChangeNotes notes = newNotes(c);
+    assertEquals(ImmutableSetMultimap.of(
+          ReviewerState.REVIEWER, new Account.Id(2)),
+        notes.getReviewers());
+
+    update = newUpdate(c, OTHER_ACCOUNT);
+    update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.CC);
+    commit(update);
+
+    notes = newNotes(c);
+    assertEquals(ImmutableSetMultimap.of(
+          ReviewerState.CC, new Account.Id(2)),
+        notes.getReviewers());
+  }
+
+  @Test
+  public void removeReviewer() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+    update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.REVIEWER);
+    commit(update);
+
+    update = newUpdate(c, CHANGE_OWNER);
+    update.putApproval("Code-Review", (short) 1);
+    commit(update);
+
+    update = newUpdate(c, OTHER_ACCOUNT);
+    update.putApproval("Code-Review", (short) 1);
+    commit(update);
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> psas =
+        notes.getApprovals().get(c.currentPatchSetId());
+    assertEquals(2, psas.size());
+    assertEquals(CHANGE_OWNER.getId(), psas.get(0).getAccountId());
+    assertEquals(OTHER_ACCOUNT.getId(), psas.get(1).getAccountId());
+
+    update = newUpdate(c, CHANGE_OWNER);
+    update.removeReviewer(OTHER_ACCOUNT.getId());
+    commit(update);
+
+    notes = newNotes(c);
+    psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertEquals(1, psas.size());
+    assertEquals(CHANGE_OWNER.getId(), psas.get(0).getAccountId());
+  }
+
+  private Change newChange() {
+    Change.Id changeId = new Change.Id(1);
+    Change c = new Change(
+        new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+        changeId,
+        CHANGE_OWNER.getId(),
+        new Branch.NameKey(project, "master"),
+        TimeUtil.nowTs());
+    incrementPatchSet(c);
+    return c;
+  }
+
+  private ChangeUpdate newUpdate(Change c, Account account)
+      throws ConfigInvalidException, IOException {
+    return new ChangeUpdate(repoManager, accountCache, LABEL_TYPES, c, account,
+        TimeUtil.nowTs(), TZ);
+  }
+
+  private ChangeNotes newNotes(Change c) throws OrmException {
+    return new ChangeNotes(repoManager, c).load();
+  }
+
+  private static void incrementPatchSet(Change change) {
+    PatchSet.Id curr = change.currentPatchSetId();
+    PatchSetInfo ps = new PatchSetInfo(new PatchSet.Id(
+        change.getId(), curr != null ? curr.get() + 1 : 1));
+    ps.setSubject("Change subject");
+    change.setCurrentPatchSet(ps);
+  }
+
+  private static Timestamp truncate(Timestamp ts) {
+    return new Timestamp((ts.getTime() / 1000) * 1000);
+  }
+
+  private static Timestamp after(Change c, long millis) {
+    return new Timestamp(c.getCreatedOn().getTime() + millis);
+  }
+
+  private RevCommit commit(ChangeUpdate update) throws IOException {
+    MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+        project, repo);
+    md.getCommitBuilder().setAuthor(new PersonIdent(
+        update.getAccount().getFullName(),
+        update.getAccount().getPreferredEmail(),
+        update.getWhen(), TZ));
+    return update.commit(md);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
index 6e59b7c..f09f2d8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
@@ -244,7 +244,7 @@
 
     return new ProjectControl(Collections.<AccountGroup.UUID> emptySet(),
         Collections.<AccountGroup.UUID> emptySet(), projectCache,
-        sectionSorter, null, changeControlFactory, canonicalWebUrl,
+        sectionSorter, repoManager, changeControlFactory, canonicalWebUrl,
         new MockUser(name, memberOf), newProjectState(local));
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
index 4e5dcdd..dd7ac56 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.query.change;
 
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
+
 import org.junit.Test;
 
 import java.util.Arrays;
 
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
 public class RegexPathPredicateTest {
   @Test
   public void testPrefixOnlyOptimization() throws OrmException {
@@ -83,7 +84,7 @@
 
   private static ChangeData change(String... files) {
     Arrays.sort(files);
-    ChangeData cd = new ChangeData(null, null, null, new Change.Id(1));
+    ChangeData cd = ChangeData.createForTest(new Change.Id(1));
     cd.setCurrentFilePaths(Arrays.asList(files));
     return cd;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
new file mode 100644
index 0000000..db1ed05
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testutil;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.util.TimeUtil;
+
+import java.util.Map;
+
+/** Fake implementation of {@link AccountCache} for testing. */
+public class FakeAccountCache implements AccountCache {
+  private final Map<Account.Id, AccountState> byId;
+  private final Map<String, AccountState> byUsername;
+
+  public FakeAccountCache() {
+    byId = Maps.newHashMap();
+    byUsername = Maps.newHashMap();
+  }
+
+  @Override
+  public synchronized AccountState get(Account.Id accountId) {
+    AccountState state = getIfPresent(accountId);
+    if (state != null) {
+      return state;
+    }
+    return newState(new Account(accountId, TimeUtil.nowTs()));
+  }
+
+  @Override
+  public synchronized AccountState getIfPresent(Account.Id accountId) {
+    return byId.get(accountId);
+  }
+
+  @Override
+  public synchronized AccountState getByUsername(String username) {
+    return byUsername.get(username);
+  }
+
+  @Override
+  public synchronized void evict(Account.Id accountId) {
+    byId.remove(accountId);
+  }
+
+  @Override
+  public synchronized void evictByUsername(String username) {
+    byUsername.remove(username);
+  }
+
+  public synchronized void put(Account account) {
+    AccountState state = newState(account);
+    byId.put(account.getId(), state);
+    if (account.getUserName() != null) {
+      byUsername.put(account.getUserName(), state);
+    }
+  }
+
+  private static AccountState newState(Account account) {
+    return new AccountState(
+        account, ImmutableSet.<AccountGroup.UUID> of(),
+        ImmutableSet.<AccountExternalId> of());
+  }
+}
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 7101a4d..cf9c733 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
@@ -148,14 +148,14 @@
     String id = cd.getId().toString();
     SolrInputDocument doc = toDocument(cd);
     try {
-      if (cd.getChange().getStatus().isOpen()) {
+      if (cd.change().getStatus().isOpen()) {
         closedIndex.deleteById(id);
         openIndex.add(doc);
       } else {
         openIndex.deleteById(id);
         closedIndex.add(doc);
       }
-    } catch (SolrServerException e) {
+    } catch (OrmException | SolrServerException e) {
       throw new IOException(e);
     }
     commit(openIndex);
@@ -167,14 +167,14 @@
     String id = cd.getId().toString();
     SolrInputDocument doc = toDocument(cd);
     try {
-      if (cd.getChange().getStatus().isOpen()) {
+      if (cd.change().getStatus().isOpen()) {
         closedIndex.deleteById(id);
         openIndex.add(doc);
       } else {
         openIndex.deleteById(id);
         closedIndex.add(doc);
       }
-    } catch (SolrServerException e) {
+    } catch (OrmException | SolrServerException e) {
       throw new IOException(e);
     }
     commit(openIndex);
@@ -185,14 +185,14 @@
   public void delete(ChangeData cd) throws IOException {
     String id = cd.getId().toString();
     try {
-      if (cd.getChange().getStatus().isOpen()) {
+      if (cd.change().getStatus().isOpen()) {
         openIndex.deleteById(id);
         commit(openIndex);
       } else {
         closedIndex.deleteById(id);
         commit(closedIndex);
       }
-    } catch (SolrServerException e) {
+    } catch (OrmException | SolrServerException e) {
       throw new IOException(e);
     }
   }