Merge changes from topic "ts-gr-diff"

* changes:
  Convert files to typescript
  Rename files to preserve history
diff --git a/Documentation/cmd-hook-commit-msg.txt b/Documentation/cmd-hook-commit-msg.txt
index 2b6d7af..e547822 100644
--- a/Documentation/cmd-hook-commit-msg.txt
+++ b/Documentation/cmd-hook-commit-msg.txt
@@ -56,6 +56,26 @@
 The `Change-Id` will not be added if `gerrit.createChangeId` is set
 to `false` in the git config.
 
+If `gerrit.reviewUrl` is set to the base URL of the Gerrit server that
+changes are uploaded to (e.g. `https://gerrit-review.googlesource.com/`)
+in the git config, then instead of adding a `Change-Id` trailer, a `Link`
+trailer will be inserted that will look like this:
+
+----
+Improve foo widget by attaching a bar.
+
+We want a bar, because it improves the foo by providing more
+wizbangery to the dowhatimeanery.
+
+Link: https://gerrit-review.googlesource.com/id/Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+Signed-off-by: A. U. Thor <author@example.com>
+----
+
+This link will become a valid link to the review page once the change is
+uploaded to the Gerrit server. Newer versions of the Gerrit server will read
+the change identifier out of the appropriate `Link` trailer and treat it in
+the same way as the change identifier in a `Change-Id` trailer.
+
 == OBTAINING
 
 To obtain the `commit-msg` script use `scp`, `wget` or `curl` to download
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 1d275b4..762fb43 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -203,6 +203,34 @@
 method uses git's link:cmd-hook-commit-msg.html[commit-msg hook]
 to automatically add the Change-Id to each new commit.
 
+== The Link footer
+
+Gerrit also supports the Link footer as an alternative to the Change-Id
+footer. A Link footer looks like this:
+
+....
+    Link: https://gerrit-review.googlesource.com/id/Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+....
+
+The advantage of this style of footer is that it usually acts
+as a link directly to the change's review page, provided that
+the change has been uploaded to Gerrit. Projects such as the
+link:https://www.kernel.org/doc/html/latest/maintainer/configure-git.html#creating-commit-links-to-lore-kernel-org[Linux kernel]
+have a convention of adding links to commit messages using the
+Link footer.
+
+If multiple changes have been uploaded to Gerrit with the same
+change ID, for example if a change has been cherry-picked to multiple
+branches, the link will take the user to a list of changes.
+
+The base URL in the footer is required to match the server's base URL.
+If the URL does not match, the server will not recognize the footer
+as a change ID footer.
+
+The link:cmd-hook-commit-msg.html[commit-msg hook] can be configured
+to insert Link footers instead of Change-Id footers by setting the
+property `gerrit.reviewUrl` to the base URL of the Gerrit server.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 13392d8..f04de17 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeUtil;
@@ -52,12 +53,16 @@
 import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.Merger;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
@@ -196,52 +201,154 @@
       TestChangeCreation changeCreation,
       PersonIdent authorAndCommitter)
       throws IOException, BadRequestException {
-    Optional<ObjectId> branchTip = getTip(repository, changeCreation.branch());
-
-    ObjectId tree =
-        createNewTree(
-            repository,
-            revWalk,
-            branchTip.orElse(ObjectId.zeroId()),
-            changeCreation.treeModifications());
-
+    ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
+    TreeCreator treeCreator =
+        getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
+    ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
     String commitMessage = correctCommitMessage(changeCreation.commitMessage());
-
-    ImmutableList<ObjectId> parentCommitIds = Streams.stream(branchTip).collect(toImmutableList());
     return createCommit(
-        objectInserter,
-        tree,
-        parentCommitIds,
-        authorAndCommitter,
-        authorAndCommitter,
-        commitMessage);
+        objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
   }
 
-  private Optional<ObjectId> getTip(Repository repository, String branch) throws IOException {
-    Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
-    return ref.map(Ref::getObjectId);
+  private ImmutableList<ObjectId> getParentCommits(
+      Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) {
+
+    return changeCreation
+        .parents()
+        .map(parents -> resolveParents(repository, revWalk, parents))
+        .orElseGet(() -> asImmutableList(getTip(repository, changeCreation.branch())));
+  }
+
+  private ImmutableList<ObjectId> resolveParents(
+      Repository repository, RevWalk revWalk, ImmutableList<TestCommitIdentifier> parents) {
+    return parents.stream()
+        .map(parent -> resolveCommit(repository, revWalk, parent))
+        .collect(toImmutableList());
+  }
+
+  private ObjectId resolveCommit(
+      Repository repository, RevWalk revWalk, TestCommitIdentifier parentCommit) {
+    switch (parentCommit.getKind()) {
+      case BRANCH:
+        return resolveBranchTip(repository, parentCommit.branch());
+      case CHANGE_ID:
+        return resolveChange(parentCommit.changeId());
+      case COMMIT_SHA_1:
+        return resolveCommitFromSha1(revWalk, parentCommit.commitSha1());
+      case PATCHSET_ID:
+        return resolvePatchset(parentCommit.patchsetId());
+      default:
+        throw new IllegalStateException(
+            String.format("No parent behavior implemented for %s.", parentCommit.getKind()));
+    }
+  }
+
+  private static ObjectId resolveBranchTip(Repository repository, String branchName) {
+    return getTip(repository, branchName)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Tip of branch %s not found and hence can't be used as parent.",
+                        branchName)));
+  }
+
+  private static Optional<ObjectId> getTip(Repository repository, String branch) {
+    try {
+      Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
+      return ref.map(Ref::getObjectId);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private ObjectId resolveChange(Change.Id changeId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+    return changeNotes
+        .map(ChangeNotes::getCurrentPatchSet)
+        .map(PatchSet::commitId)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Change %s not found and hence can't be used as parent.", changeId)));
+  }
+
+  private static RevCommit resolveCommitFromSha1(RevWalk revWalk, ObjectId commitSha1) {
+    try {
+      return revWalk.parseCommit(commitSha1);
+    } catch (Exception e) {
+      throw new IllegalStateException(
+          String.format("Commit %s not found and hence can't be used as parent/base.", commitSha1),
+          e);
+    }
+  }
+
+  private ObjectId resolvePatchset(PatchSet.Id patchsetId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId());
+    return changeNotes
+        .map(ChangeNotes::getPatchSets)
+        .map(patchsets -> patchsets.get(patchsetId))
+        .map(PatchSet::commitId)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Patchset %s not found and hence can't be used as parent.", patchsetId)));
+  }
+
+  private static <T> ImmutableList<T> asImmutableList(Optional<T> value) {
+    return Streams.stream(value).collect(toImmutableList());
+  }
+
+  private static TreeCreator getTreeCreator(
+      RevWalk revWalk, ObjectId customBaseCommit, ImmutableList<ObjectId> parentCommits) {
+    RevCommit commit = resolveCommitFromSha1(revWalk, customBaseCommit);
+    // Use actual parents; relevant for example when a file is restored (->
+    // RestoreFileModification).
+    return TreeCreator.basedOnTree(commit.getTree(), parentCommits);
+  }
+
+  private static TreeCreator getTreeCreator(
+      ObjectInserter objectInserter,
+      ImmutableList<ObjectId> parentCommits,
+      MergeStrategy mergeStrategy) {
+    if (parentCommits.isEmpty()) {
+      return TreeCreator.basedOnEmptyTree();
+    }
+    ObjectId baseTreeId = merge(objectInserter, parentCommits, mergeStrategy);
+    return TreeCreator.basedOnTree(baseTreeId, parentCommits);
+  }
+
+  private static ObjectId merge(
+      ObjectInserter objectInserter,
+      ImmutableList<ObjectId> parentCommits,
+      MergeStrategy mergeStrategy) {
+    try {
+      Merger merger = mergeStrategy.newMerger(objectInserter, new Config());
+      boolean mergeSuccessful = merger.merge(parentCommits.toArray(new AnyObjectId[0]));
+      if (!mergeSuccessful) {
+        throw new IllegalStateException(
+            "Conflicts encountered while merging the specified parents. Use"
+                + " mergeOfButBaseOnFirst() instead to avoid these conflicts and define any"
+                + " other desired file contents with file().content().");
+      }
+      return merger.getResultTreeId();
+    } catch (IOException e) {
+      throw new IllegalStateException(
+          "Creating the merge commits of the specified parents failed for an unknown reason.", e);
+    }
   }
 
   private static ObjectId createNewTree(
       Repository repository,
-      RevWalk revWalk,
-      ObjectId baseCommitId,
+      TreeCreator treeCreator,
       ImmutableList<TreeModification> treeModifications)
       throws IOException {
-    TreeCreator treeCreator = getTreeCreator(revWalk, baseCommitId);
     treeCreator.addTreeModifications(treeModifications);
     return treeCreator.createNewTreeAndGetId(repository);
   }
 
-  private static TreeCreator getTreeCreator(RevWalk revWalk, ObjectId baseCommitId)
-      throws IOException {
-    if (ObjectId.zeroId().equals(baseCommitId)) {
-      return TreeCreator.basedOnEmptyTree();
-    }
-    RevCommit baseCommit = revWalk.parseCommit(baseCommitId);
-    return TreeCreator.basedOn(baseCommit);
-  }
-
   private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException {
     String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
 
@@ -352,16 +459,16 @@
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
       RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
 
-      ObjectId tree =
-          createNewTree(
-              repository, revWalk, oldPatchsetCommitId, patchsetCreation.treeModifications());
+      ImmutableList<ObjectId> parentCommitIds =
+          getParents(repository, revWalk, patchsetCreation, oldPatchsetCommit);
+      TreeCreator treeCreator = getTreeCreator(revWalk, oldPatchsetCommit, parentCommitIds);
+      ObjectId tree = createNewTree(repository, treeCreator, patchsetCreation.treeModifications());
 
       String commitMessage =
           correctCommitMessage(
               changeNotes.getChange().getKey().get(),
               patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
 
-      ImmutableList<ObjectId> parentCommitIds = getParents(oldPatchsetCommit);
       PersonIdent author = getAuthor(oldPatchsetCommit);
       PersonIdent committer = getCommitter(oldPatchsetCommit, now);
       return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
@@ -404,10 +511,16 @@
       return date.getTime() / 1000;
     }
 
-    private ImmutableList<ObjectId> getParents(RevCommit oldPatchsetCommit) {
-      return Arrays.stream(oldPatchsetCommit.getParents())
-          .map(ObjectId::toObjectId)
-          .collect(toImmutableList());
+    private ImmutableList<ObjectId> getParents(
+        Repository repository,
+        RevWalk revWalk,
+        TestPatchsetCreation patchsetCreation,
+        RevCommit oldPatchsetCommit) {
+      return patchsetCreation
+          .parents()
+          .map(parents -> resolveParents(repository, revWalk, parents))
+          .orElseGet(
+              () -> Arrays.stream(oldPatchsetCommit.getParents()).collect(toImmutableList()));
     }
 
     private PatchSetInserter getPatchSetInserter(
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java
new file mode 100644
index 0000000..63d8c0a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.common.collect.ImmutableList;
+import java.util.function.Function;
+
+/** Builder to simplify specifying multiple parents for a change. */
+public class MultipleParentBuilder<T> {
+  private final Function<ImmutableList<TestCommitIdentifier>, T> parentsToBuilderAdder;
+  private final ImmutableList.Builder<TestCommitIdentifier> parents;
+
+  public MultipleParentBuilder(
+      Function<ImmutableList<TestCommitIdentifier>, T> parentsToBuilderAdder,
+      TestCommitIdentifier firstParent) {
+    this.parentsToBuilderAdder = parentsToBuilderAdder;
+    parents = ImmutableList.builder();
+    parents.add(firstParent);
+  }
+
+  /** Adds an intermediate parent. */
+  public ParentBuilder<MultipleParentBuilder<T>> followedBy() {
+    return new ParentBuilder<>(
+        parent -> {
+          parents.add(parent);
+          return this;
+        });
+  }
+
+  /** Adds the last parent. */
+  public ParentBuilder<T> and() {
+    return new ParentBuilder<>(
+        (parent) -> parentsToBuilderAdder.apply(parents.add(parent).build()));
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java
new file mode 100644
index 0000000..b57aa6d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Builder to simplify parent specification of a change. */
+public class ParentBuilder<T> {
+  private final Function<TestCommitIdentifier, T> parentToBuilderAdder;
+
+  public ParentBuilder(Function<TestCommitIdentifier, T> parentToBuilderAdder) {
+    this.parentToBuilderAdder = parentToBuilderAdder;
+  }
+
+  /** Use the commit identified by the specified SHA-1. */
+  public T commit(ObjectId commitSha1) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofCommitSha1(commitSha1));
+  }
+
+  /**
+   * Use the commit which is at the tip of the specified branch. Short branch names (without
+   * refs/heads) are automatically expanded.
+   */
+  public T tipOfBranch(String branchName) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofBranch(branchName));
+  }
+
+  /** Use the current patchset commit of the indicated change. */
+  public T change(Change.Id changeId) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofChangeId(changeId));
+  }
+
+  /** Use the commit identified by the specified patchset. */
+  public T patchset(PatchSet.Id patchsetId) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofPatchsetId(patchsetId));
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index 65db967..5871e17 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.merge.MergeStrategy;
 
 /** Initial attributes of the change. If not provided, arbitrary values will be used. */
 @AutoValue
@@ -37,13 +38,19 @@
 
   public abstract ImmutableList<TreeModification> treeModifications();
 
+  public abstract Optional<ImmutableList<TestCommitIdentifier>> parents();
+
+  public abstract MergeStrategy mergeStrategy();
+
   abstract ThrowingFunction<TestChangeCreation, Change.Id> changeCreator();
 
   public static Builder builder(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator) {
     return new AutoValue_TestChangeCreation.Builder()
         .changeCreator(changeCreator)
         .branch(Constants.R_HEADS + Constants.MASTER)
-        .commitMessage("A test change");
+        .commitMessage("A test change")
+        // Which value we choose here doesn't matter. All relevant code paths set the desired value.
+        .mergeStrategy(MergeStrategy.OURS);
   }
 
   @AutoValue.Builder
@@ -72,6 +79,53 @@
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
 
+    /**
+     * Parent commit of the change. The commit can be specified via various means in the returned
+     * builder.
+     */
+    public ParentBuilder<Builder> childOf() {
+      return new ParentBuilder<>(parentCommit -> parents(ImmutableList.of(parentCommit)));
+    }
+
+    /**
+     * Parent commits of the change. Each parent commit can be specified via various means in the
+     * returned builder. The order of the parents matters and is preserved (first parent commit in
+     * fluent change -> first parent of the change).
+     *
+     * <p>This method will automatically merge the parent commits and use the resulting commit as
+     * base for the change. Use {@link #file(String)} for additional file adjustments on top of that
+     * merge commit.
+     *
+     * <p><strong>Note:</strong> If this method fails with a merge conflict, use {@link
+     * #mergeOfButBaseOnFirst()} instead and specify all other necessary file contents manually via
+     * {@link #file(String)}.
+     */
+    public ParentBuilder<MultipleParentBuilder<Builder>> mergeOf() {
+      return new ParentBuilder<>(parent -> mergeBuilder(MergeStrategy.RECURSIVE, parent));
+    }
+
+    /**
+     * Parent commits of the change. Each parent commit can be specified via various means in the
+     * returned builder. The order of the parents matters and is preserved (first parent commit in
+     * fluent change -> first parent of the change).
+     *
+     * <p>This method will use the first specified parent commit as base for the resulting change.
+     * This approach is especially useful if merging the parents is not possible.
+     */
+    public ParentBuilder<MultipleParentBuilder<Builder>> mergeOfButBaseOnFirst() {
+      return new ParentBuilder<>(parent -> mergeBuilder(MergeStrategy.OURS, parent));
+    }
+
+    MultipleParentBuilder<Builder> mergeBuilder(
+        MergeStrategy mergeStrategy, TestCommitIdentifier parent) {
+      mergeStrategy(mergeStrategy);
+      return new MultipleParentBuilder<>(this::parents, parent);
+    }
+
+    abstract Builder parents(ImmutableList<TestCommitIdentifier> parents);
+
+    abstract Builder mergeStrategy(MergeStrategy mergeStrategy);
+
     abstract Builder changeCreator(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator);
 
     abstract TestChangeCreation autoBuild();
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java
new file mode 100644
index 0000000..a352607
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoOneOf;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Attributes, each one uniquely identifying a commit. */
+@AutoOneOf(TestCommitIdentifier.Kind.class)
+public abstract class TestCommitIdentifier {
+  public enum Kind {
+    COMMIT_SHA_1,
+    BRANCH,
+    CHANGE_ID,
+    PATCHSET_ID
+  }
+
+  public abstract Kind getKind();
+
+  /** SHA-1 of the commit. */
+  public abstract ObjectId commitSha1();
+
+  /** Branch whose tip points to the desired commit. */
+  public abstract String branch();
+
+  /** Numeric ID of the change whose current patchset points to the desired commit. */
+  public abstract Change.Id changeId();
+
+  /** ID of the patchset representing the desired commit. */
+  public abstract PatchSet.Id patchsetId();
+
+  public static TestCommitIdentifier ofCommitSha1(ObjectId commitSha1) {
+    return AutoOneOf_TestCommitIdentifier.commitSha1(commitSha1);
+  }
+
+  public static TestCommitIdentifier ofBranch(String branchName) {
+    return AutoOneOf_TestCommitIdentifier.branch(branchName);
+  }
+
+  public static TestCommitIdentifier ofChangeId(Change.Id changeId) {
+    return AutoOneOf_TestCommitIdentifier.changeId(changeId);
+  }
+
+  public static TestCommitIdentifier ofPatchsetId(PatchSet.Id patchsetId) {
+    return AutoOneOf_TestCommitIdentifier.patchsetId(patchsetId);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index 0152cd1..fe9d909 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -29,6 +29,8 @@
 
   public abstract ImmutableList<TreeModification> treeModifications();
 
+  public abstract Optional<ImmutableList<TestCommitIdentifier>> parents();
+
   abstract ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator();
 
   public static TestPatchsetCreation.Builder builder(
@@ -48,6 +50,37 @@
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
 
+    /**
+     * Parent commit of the change. The commit can be specified via various means in the returned
+     * builder.
+     *
+     * <p>This method will just change the parent but not influence the contents of the patchset
+     * commit.
+     *
+     * <p>It's possible to switch from a change representing a merge commit to a change not being a
+     * merge commit with this method.
+     */
+    public ParentBuilder<Builder> parent() {
+      return new ParentBuilder<>(parent -> parents(ImmutableList.of(parent)));
+    }
+
+    /**
+     * Parent commits of the change. Each parent commit can be specified via various means in the
+     * returned builder. The order of the parents matters and is preserved (first parent commit in
+     * fluent change -> first parent of the change).
+     *
+     * <p>This method will just change the parents but not influence the contents of the patchset
+     * commit.
+     *
+     * <p>It's possible to switch from a change representing a non-merge commit to a change which is
+     * a merge commit with this method.
+     */
+    public ParentBuilder<MultipleParentBuilder<Builder>> parents() {
+      return new ParentBuilder<>(parent -> new MultipleParentBuilder<>(this::parents, parent));
+    }
+
+    abstract Builder parents(ImmutableList<TestCommitIdentifier> value);
+
     abstract TestPatchsetCreation.Builder patchsetCreator(
         ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator);
 
diff --git a/java/com/google/gerrit/common/FooterConstants.java b/java/com/google/gerrit/common/FooterConstants.java
index 3ec809c..656d850 100644
--- a/java/com/google/gerrit/common/FooterConstants.java
+++ b/java/com/google/gerrit/common/FooterConstants.java
@@ -20,6 +20,9 @@
   /** The change ID as used to track patch sets. */
   public static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
 
+  /** Link is an alternative footer that may be used to track patch sets. */
+  public static final FooterKey LINK = new FooterKey("Link");
+
   /** The footer telling us who reviewed the change. */
   public static final FooterKey REVIEWED_BY = new FooterKey("Reviewed-by");
 
diff --git a/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java b/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java
index 2dc94b0..4033c3e 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java
+++ b/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java
@@ -66,11 +66,11 @@
   }
 
   /**
-   * Returns a {@link ComparableSubject} for the {@link AttentionSetUpdate.Operation} of attention
-   * set update.
+   * Returns a {@link ComparableSubject} for the {@link
+   * com.google.gerrit.entities.AttentionSetUpdate.Operation} of attention set update.
    *
-   * @return {@link ComparableSubject} for the {@link AttentionSetUpdate.Operation} of attention set
-   *     update
+   * @return {@link ComparableSubject} for the {@link
+   *     com.google.gerrit.entities.AttentionSetUpdate.Operation} of attention set update.
    */
   public ComparableSubject<AttentionSetUpdate.Operation> hasOperationThat() {
     return check("operation()").that(attentionSetUpdate().operation());
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 414a120..4b2c8a9 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -69,6 +69,7 @@
       ImmutableList.of(
           "/",
           "/c/*",
+          "/id/*",
           "/p/*",
           "/q/*",
           "/x/*",
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index a166d97..eea1052 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -19,17 +19,24 @@
 
 import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
 import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
 import java.util.Random;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class ChangeUtil {
@@ -106,5 +113,29 @@
     return c != null ? c.getStatus().name().toLowerCase() : "deleted";
   }
 
+  private static final Pattern LINK_CHANGE_ID_PATTERN = Pattern.compile("I[0-9a-f]{40}");
+
+  public static List<String> getChangeIdsFromFooter(RevCommit c, UrlFormatter urlFormatter) {
+    List<String> changeIds = c.getFooterLines(FooterConstants.CHANGE_ID);
+    Optional<String> webUrl = urlFormatter.getWebUrl();
+    if (!webUrl.isPresent()) {
+      return changeIds;
+    }
+
+    String prefix = webUrl.get() + "id/";
+    for (String link : c.getFooterLines(FooterConstants.LINK)) {
+      if (!link.startsWith(prefix)) {
+        continue;
+      }
+      String changeId = link.substring(prefix.length());
+      Matcher m = LINK_CHANGE_ID_PATTERN.matcher(changeId);
+      if (m.matches()) {
+        changeIds.add(changeId);
+      }
+    }
+
+    return changeIds;
+  }
+
   private ChangeUtil() {}
 }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 81969f0..30913f7 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -57,6 +57,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -411,15 +412,33 @@
         ps.id(),
         c);
     if (c.getCommitId() == null) {
-      if (Side.fromShort(c.side) == Side.PARENT) {
-        if (c.side < 0) {
-          c.setCommitId(cache.getOldId(change, ps, -c.side));
-        } else {
-          c.setCommitId(cache.getOldId(change, ps, null));
-        }
+      c.setCommitId(determineCommitId(cache, change, ps, c.side));
+    }
+  }
+
+  /**
+   * Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
+   *
+   * @param patchListCache the cache to use for SHA-1 lookups
+   * @param change the change to which the commit belongs
+   * @param patchset the patchset to which the commit belongs
+   * @param side the side indicating which commit of the patchset to take. 1 is the patchset commit,
+   *     0 the parent commit (or auto-merge for changes representing merge commits); -x the xth
+   *     parent commit of a merge commit
+   * @return the commit SHA-1
+   * @throws PatchListNotAvailableException if the SHA-1 is unavailable for an unknown reason
+   */
+  public static ObjectId determineCommitId(
+      PatchListCache patchListCache, Change change, PatchSet patchset, short side)
+      throws PatchListNotAvailableException {
+    if (Side.fromShort(side) == Side.PARENT) {
+      if (side < 0) {
+        return patchListCache.getOldId(change, patchset, -side);
       } else {
-        c.setCommitId(ps.commitId());
+        return patchListCache.getOldId(change, patchset, null);
       }
+    } else {
+      return patchset.commitId();
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index f49a21e..a086cb1 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -28,7 +28,6 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -41,17 +40,20 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
@@ -110,6 +112,7 @@
   private final CommentAdded commentAdded;
   private final ReviewerAdder reviewerAdder;
   private final MessageIdGenerator messageIdGenerator;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -159,6 +162,7 @@
       RevisionCreated revisionCreated,
       ReviewerAdder reviewerAdder,
       MessageIdGenerator messageIdGenerator,
+      DynamicItem<UrlFormatter> urlFormatter,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -175,6 +179,7 @@
     this.commentAdded = commentAdded;
     this.reviewerAdder = reviewerAdder;
     this.messageIdGenerator = messageIdGenerator;
+    this.urlFormatter = urlFormatter;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -191,7 +196,7 @@
   public Change createChange(Context ctx) throws IOException {
     change =
         new Change(
-            getChangeKey(ctx.getRevWalk(), commitId),
+            getChangeKey(ctx.getRevWalk()),
             changeId,
             ctx.getAccountId(),
             BranchNameKey.create(ctx.getProject(), refName),
@@ -206,10 +211,10 @@
     return change;
   }
 
-  private static Change.Key getChangeKey(RevWalk rw, ObjectId id) throws IOException {
-    RevCommit commit = rw.parseCommit(id);
+  private Change.Key getChangeKey(RevWalk rw) throws IOException {
+    RevCommit commit = rw.parseCommit(commitId);
     rw.parseBody(commit);
-    List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+    List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
     if (!idList.isEmpty()) {
       return Change.key(idList.get(idList.size() - 1).trim());
     }
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 61616c0..7d0bda1 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -28,7 +28,6 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -38,12 +37,14 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.PatchSetState;
@@ -115,6 +116,7 @@
   private final Provider<CurrentUser> user;
   private final Provider<PersonIdent> serverIdent;
   private final RetryHelper retryHelper;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   private BatchUpdate.Factory updateFactory;
   private FixInput fix;
@@ -141,7 +143,8 @@
       PatchSetInserter.Factory patchSetInserterFactory,
       PatchSetUtil psUtil,
       Provider<CurrentUser> user,
-      RetryHelper retryHelper) {
+      RetryHelper retryHelper,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.accounts = accounts;
     this.accountPatchReviewStore = accountPatchReviewStore;
     this.notesFactory = notesFactory;
@@ -152,6 +155,7 @@
     this.retryHelper = retryHelper;
     this.serverIdent = serverIdent;
     this.user = user;
+    this.urlFormatter = urlFormatter;
     reset();
   }
 
@@ -456,7 +460,8 @@
           // No patch set for this commit; insert one.
           rw.parseBody(commit);
           String changeId =
-              Iterables.getFirst(commit.getFooterLines(FooterConstants.CHANGE_ID), null);
+              Iterables.getFirst(
+                  ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get()), null);
           // Missing Change-Id footer is ok, but mismatched is not.
           if (changeId != null && !changeId.equals(change().getKey().get())) {
             problem(
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index 45f877e..dfc1ffb 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -46,6 +46,12 @@
     return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
   }
 
+  public static TreeCreator basedOnTree(
+      ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+    requireNonNull(baseTreeId, "baseTreeId is required");
+    return new TreeCreator(baseTreeId, baseParents);
+  }
+
   public static TreeCreator basedOnEmptyTree() {
     return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
   }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 7518a14..8666f26 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
@@ -531,7 +532,7 @@
       msgbuf.append('\n');
     }
 
-    if (!contains(footers, FooterConstants.CHANGE_ID, c.getKey().get())) {
+    if (ChangeUtil.getChangeIdsFromFooter(n, urlFormatter.get()).isEmpty()) {
       msgbuf.append(FooterConstants.CHANGE_ID.getName());
       msgbuf.append(": ");
       msgbuf.append(c.getKey().get());
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 1d10045..015ed0b 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -60,7 +60,6 @@
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
@@ -117,6 +116,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.BanCommit;
@@ -347,6 +347,7 @@
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   // Assisted injected fields.
   private final ProjectState projectState;
@@ -427,6 +428,7 @@
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
+      DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
@@ -475,6 +477,7 @@
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
+    this.urlFormatter = urlFormatter;
 
     // Assisted injected fields.
     this.projectState = projectState;
@@ -2084,7 +2087,7 @@
       } catch (IOException e) {
         throw new StorageException("Can't parse commit", e);
       }
-      List<String> idList = create.commit.getFooterLines(FooterConstants.CHANGE_ID);
+      List<String> idList = ChangeUtil.getChangeIdsFromFooter(create.commit, urlFormatter.get());
 
       if (idList.isEmpty()) {
         messages.add(
@@ -2176,7 +2179,7 @@
             }
           }
 
-          List<String> idList = c.getFooterLines(FooterConstants.CHANGE_ID);
+          List<String> idList = ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get());
           if (!idList.isEmpty()) {
             pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim())));
           } else {
@@ -3315,7 +3318,8 @@
                         }
                       }
 
-                      for (String changeId : c.getFooterLines(FooterConstants.CHANGE_ID)) {
+                      for (String changeId :
+                          ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get())) {
                         if (byKey == null) {
                           byKey =
                               retryHelper
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index cdec2cd..ce62d7a 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
@@ -41,11 +40,13 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.AddReviewersOp;
@@ -56,6 +57,7 @@
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
@@ -131,6 +133,7 @@
   private final ReviewerAdder reviewerAdder;
   private final Change change;
   private final MessageIdGenerator messageIdGenerator;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final ProjectState projectState;
   private final BranchNameKey dest;
@@ -175,6 +178,7 @@
       ReviewerAdder reviewerAdder,
       Change change,
       MessageIdGenerator messageIdGenerator,
+      DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
       @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
@@ -202,6 +206,7 @@
     this.reviewerAdder = reviewerAdder;
     this.change = change;
     this.messageIdGenerator = messageIdGenerator;
+    this.urlFormatter = urlFormatter;
 
     this.projectState = projectState;
     this.dest = dest;
@@ -483,7 +488,7 @@
     change.setStatus(Change.Status.NEW);
     change.setCurrentPatchSet(info);
 
-    List<String> idList = commit.getFooterLines(CHANGE_ID);
+    List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
     change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 923ba68..c67df8b 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
@@ -302,7 +303,7 @@
       }
       RevCommit commit = receiveEvent.commit;
       List<CommitValidationMessage> messages = new ArrayList<>();
-      List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+      List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter);
 
       if (idList.isEmpty()) {
         String shortMsg = commit.getShortMessage();
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 7ed2491..812d98d 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.or;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
@@ -36,13 +35,16 @@
 import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo.AutoCloseableChangesCheckResult;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -75,17 +77,20 @@
   private final RetryHelper retryHelper;
   private final ChangeJson.Factory changeJsonFactory;
   private final IndexConfig indexConfig;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   ProjectsConsistencyChecker(
       GitRepositoryManager repoManager,
       RetryHelper retryHelper,
       ChangeJson.Factory changeJsonFactory,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.repoManager = repoManager;
     this.retryHelper = retryHelper;
     this.changeJsonFactory = changeJsonFactory;
     this.indexConfig = indexConfig;
+    this.urlFormatter = urlFormatter;
   }
 
   public CheckProjectResultInfo check(Project.NameKey projectName, CheckProjectInput input)
@@ -172,7 +177,7 @@
         mergedSha1s.add(commitId);
 
         // Consider all Change-Id lines since this is what ReceiveCommits#autoCloseChanges does.
-        List<String> changeIds = commit.getFooterLines(CHANGE_ID);
+        List<String> changeIds = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
 
         // Number of predicates that we need to add for this commit, 1 per Change-Id plus one for
         // the commit.
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 44dc6e1..5fab976 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -20,7 +20,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
@@ -29,6 +28,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -112,6 +113,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   CherryPickChange(
@@ -128,7 +130,8 @@
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
       NotifyResolver notifyResolver,
-      BatchUpdate.Factory batchUpdateFactory) {
+      BatchUpdate.Factory batchUpdateFactory,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -143,6 +146,7 @@
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.urlFormatter = urlFormatter;
   }
 
   /**
@@ -312,7 +316,8 @@
                 input.allowConflicts);
 
         Change.Key changeKey;
-        final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
+        final List<String> idList =
+            ChangeUtil.getChangeIdsFromFooter(cherryPickCommit, urlFormatter.get());
         if (!idList.isEmpty()) {
           final String idStr = idList.get(idList.size() - 1).trim();
           changeKey = Change.key(idStr);
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 2e9d21a..190deb5 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,6 +35,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -73,6 +75,7 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -84,7 +87,8 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -94,6 +98,7 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
+    this.urlFormatter = urlFormatter;
   }
 
   @Override
@@ -200,7 +205,7 @@
     }
   }
 
-  private static String ensureChangeIdIsCorrect(
+  private String ensureChangeIdIsCorrect(
       boolean requireChangeId, String currentChangeId, String newCommitMessage)
       throws ResourceConflictException, BadRequestException {
     RevCommit revCommit =
@@ -210,7 +215,7 @@
     // Check that the commit message without footers is not empty
     CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
 
-    List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
+    List<String> changeIdFooters = ChangeUtil.getChangeIdsFromFooter(revCommit, urlFormatter.get());
     if (!changeIdFooters.isEmpty() && !changeIdFooters.get(0).equals(currentChangeId)) {
       throw new ResourceConflictException("wrong Change-Id footer");
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
index 68307cf..f4cf96d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
@@ -49,10 +49,11 @@
     assertThat(getChanges(queryChanges)).hasSize(0);
 
     try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
-      List<ChangeInfo> changes = getChanges(queryChanges);
+      List<?> changes = getChanges(queryChanges);
       assertThat(changes).hasSize(1);
 
-      String outputChangeId = ((ChangeInfo) changes.get(0)).changeId;
+      ChangeInfo c = (ChangeInfo) changes.get(0);
+      String outputChangeId = c.changeId;
       assertThat(outputChangeId).isEqualTo(evenChangeId);
       assertThat(outputChangeId).isNotEqualTo(oddChangeId);
     }
@@ -95,8 +96,8 @@
     }
   }
 
-  private List<ChangeInfo> getChanges(QueryChanges queryChanges)
+  private List<?> getChanges(QueryChanges queryChanges)
       throws AuthException, PermissionBackendException, BadRequestException {
-    return (List<ChangeInfo>) queryChanges.apply(TopLevelResource.INSTANCE).value();
+    return queryChanges.apply(TopLevelResource.INSTANCE).value();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 6fb444f..e994d03 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1557,6 +1557,27 @@
   }
 
   @Test
+  public void pushWithLinkFooter() throws Exception {
+    String changeId = "I0123456789abcdef0123456789abcdef01234567";
+    String url = cfg.getString("gerrit", null, "canonicalWebUrl");
+    if (!url.endsWith("/")) {
+      url += "/";
+    }
+    RevCommit c = createCommit(testRepo, "test commit\n\nLink: " + url + "id/" + changeId);
+    pushForReviewOk(testRepo);
+
+    List<ChangeMessageInfo> messages = getMessages(changeId);
+    assertThat(messages.get(0).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void pushWithWrongHostLinkFooter() throws Exception {
+    String changeId = "I0123456789abcdef0123456789abcdef01234567";
+    RevCommit c = createCommit(testRepo, "test commit\n\nLink: https://wronghost/id/" + changeId);
+    pushForReviewRejected(testRepo, "missing Change-Id in message footer");
+  }
+
+  @Test
   public void pushWithChangeIdAboveFooterWithCreateNewChangeForAllNotInTarget() throws Exception {
     enableCreateNewChangeForAllNotInTarget();
     testPushWithChangeIdAboveFooter();
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index e293edf..975d7ec 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -43,6 +44,7 @@
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -154,7 +156,417 @@
         .parents()
         .onlyElement()
         .commit()
-        .isEqualTo(parentCommitId.getName());
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedBranchTipAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .tipOfBranch("refs/heads/test-branch")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentBranchMayHaveShortName() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().tipOfBranch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentBranchMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations.newChange().childOf().tipOfBranch("not-existing-branch").create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedChangeAsParent() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parentCommitId =
+        changeOperations.change(parentChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentChangeMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> changeOperations.newChange().childOf().change(Change.id(987654321)).create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedPatchsetAsParent() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+    TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+
+    Change.Id changeId =
+        changeOperations.newChange().childOf().patchset(parentPatchset.patchsetId()).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentPatchset.commitId().name());
+  }
+
+  @Test
+  public void changeOfSpecifiedParentPatchsetMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .childOf()
+                    .patchset(PatchSet.id(Change.id(987654321), 1))
+                    .create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void specifiedParentPatchsetMustExist() {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .childOf()
+                    .patchset(PatchSet.id(parentChangeId, 1000))
+                    .create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedCommitAsParent() throws Exception {
+    // Currently, the easiest way to create a commit is by creating another change.
+    Change.Id anotherChangeId = changeOperations.newChange().create();
+    ObjectId parentCommitId =
+        changeOperations.change(anotherChangeId).currentPatchset().get().commitId();
+
+    Change.Id changeId = changeOperations.newChange().childOf().commit(parentCommitId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentCommitMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .childOf()
+                    .commit(ObjectId.fromString("0123456789012345678901234567890123456789"))
+                    .create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedChangesInGivenOrderAsParents() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parent1CommitId =
+        changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+    ObjectId parent2CommitId =
+        changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(parent1CommitId.name(), parent2CommitId.name())
+        .inOrder();
+  }
+
+  @Test
+  public void createdChangeUsesMergedParentsAsBaseCommit() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Line 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Some other content").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Line 1");
+    BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+    assertThat(file2Content).asString().isEqualTo("Some other content");
+  }
+
+  @Test
+  public void mergeConflictsOfParentsAreReported() {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file1").content("Content 2").create();
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .mergeOf()
+                    .change(parent1ChangeId)
+                    .and()
+                    .change(parent2ChangeId)
+                    .create());
+
+    assertThat(exception).hasMessageThat().ignoringCase().contains("conflict");
+  }
+
+  @Test
+  public void mergeConflictsCanBeAvoidedByUsingTheFirstParentAsBase() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file1").content("Content 2").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Content 1");
+  }
+
+  @Test
+  public void createdChangeHasAllParentsEvenWhenBasedOnFirst() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parent1CommitId =
+        changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+    ObjectId parent2CommitId =
+        changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(parent1CommitId.name(), parent2CommitId.name())
+        .inOrder();
+  }
+
+  @Test
+  public void automaticMergeOfMoreThanTwoParentsIsNotPossible() {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Content 2").create();
+    Change.Id parent3ChangeId =
+        changeOperations.newChange().file("file3").content("Content 3").create();
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .mergeOf()
+                    .change(parent1ChangeId)
+                    .followedBy()
+                    .change(parent2ChangeId)
+                    .and()
+                    .change(parent3ChangeId)
+                    .create());
+
+    assertThat(exception).hasMessageThat().ignoringCase().contains("conflict");
+  }
+
+  @Test
+  public void createdChangeCanHaveMoreThanTwoParentsWhenBasedOnFirst() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Content 2").create();
+    Change.Id parent3ChangeId =
+        changeOperations.newChange().file("file3").content("Content 3").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .followedBy()
+            .change(parent2ChangeId)
+            .and()
+            .change(parent3ChangeId)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parent1CommitId =
+        changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+    ObjectId parent2CommitId =
+        changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+    ObjectId parent3CommitId =
+        changeOperations.change(parent3ChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(parent1CommitId.name(), parent2CommitId.name(), parent3CommitId.name())
+        .inOrder();
+  }
+
+  @Test
+  public void changeBasedOnParentMayHaveAdditionalFileModifications() throws Exception {
+    Change.Id parentChangeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Content 1")
+            .file("file2")
+            .content("Content 2")
+            .create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .childOf()
+            .change(parentChangeId)
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Different content");
+    BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+    assertThat(file2Content).asString().isEqualTo("Content 2");
+  }
+
+  @Test
+  public void changeFromMergedParentsMayHaveAdditionalFileModifications() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Content 2").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Different content");
+    BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+    assertThat(file2Content).asString().isEqualTo("Content 2");
+  }
+
+  @Test
+  public void changeBasedOnFirstOfMultipleParentsMayHaveAdditionalFileModifications()
+      throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Different content");
   }
 
   @Test
@@ -626,6 +1038,105 @@
   }
 
   @Test
+  public void newPatchsetCanHaveADifferentParent() throws Exception {
+    Change.Id originalParentChange = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations.newChange().childOf().change(originalParentChange).create();
+    Change.Id newParentChange = changeOperations.newChange().create();
+
+    changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId newParentCommitId =
+        changeOperations.change(newParentChange).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(newParentCommitId.name());
+  }
+
+  @Test
+  public void newPatchsetCanHaveDifferentParents() throws Exception {
+    Change.Id originalParent1Change = changeOperations.newChange().create();
+    Change.Id originalParent2Change = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(originalParent1Change)
+            .and()
+            .change(originalParent2Change)
+            .create();
+    Change.Id newParent1Change = changeOperations.newChange().create();
+    Change.Id newParent2Change = changeOperations.newChange().create();
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .parents()
+        .change(newParent1Change)
+        .and()
+        .change(newParent2Change)
+        .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId newParent1CommitId =
+        changeOperations.change(newParent1Change).currentPatchset().get().commitId();
+    ObjectId newParent2CommitId =
+        changeOperations.change(newParent2Change).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(newParent1CommitId.name(), newParent2CommitId.name());
+  }
+
+  @Test
+  public void newPatchsetCanHaveADifferentNumberOfParents() throws Exception {
+    Change.Id originalParentChange = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations.newChange().childOf().change(originalParentChange).create();
+    Change.Id newParent1Change = changeOperations.newChange().create();
+    Change.Id newParent2Change = changeOperations.newChange().create();
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .parents()
+        .change(newParent1Change)
+        .and()
+        .change(newParent2Change)
+        .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId newParent1CommitId =
+        changeOperations.change(newParent1Change).currentPatchset().get().commitId();
+    ObjectId newParent2CommitId =
+        changeOperations.change(newParent2Change).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(newParent1CommitId.name(), newParent2CommitId.name());
+  }
+
+  @Test
+  public void newPatchsetKeepsFileContentsWithDifferentParent() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().file("file1").content("Actual change content").create();
+    Change.Id newParentChange =
+        changeOperations.newChange().file("file1").content("Parent content").create();
+
+    changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Actual change content");
+  }
+
+  @Test
   public void publishedCommentCanBeRetrieved() {
     Change.Id changeId = changeOperations.newChange().create();
     String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
@@ -721,4 +1232,8 @@
       throws RestApiException {
     return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
   }
+
+  private Correspondence<CommitInfo, String> hasSha1() {
+    return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasSha1");
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 7bac47d..2174791 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -78,7 +78,8 @@
   MISMATCH: 'mismatch',
   MISSING: 'missing',
 };
-const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
+const CHANGE_ID_REGEX_PATTERN =
+  /^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
 const DEFAULT_NUM_FILES_SHOWN = 200;
@@ -1325,7 +1326,7 @@
     let changeIdArr;
 
     while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
-      changeId = changeIdArr[1];
+      changeId = changeIdArr[2];
     }
 
     if (changeId) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
index 7c07e68..0e55494 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
@@ -15,10 +15,8 @@
  * limitations under the License.
  */
 
-export const GrFileListConstants = {
-  FilesExpandedState: {
-    ALL: 'all',
-    NONE: 'none',
-    SOME: 'some',
-  },
-};
+export enum FilesExpandedState {
+  ALL = 'all',
+  NONE = 'none',
+  SOME = 'some',
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
deleted file mode 100644
index 05f2ab0..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ /dev/null
@@ -1,302 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector.js';
-import '../../diff/gr-patch-range-select/gr-patch-range-select.js';
-import '../../edit/gr-edit-controls/gr-edit-controls.js';
-import '../../shared/gr-editable-label/gr-editable-label.js';
-import '../../shared/gr-linked-chip/gr-linked-chip.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-commit-info/gr-commit-info.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-file-list-header_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {
-  computeLatestPatchNum,
-  getRevisionByPatchNum,
-  patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-const MERGED_STATUS = 'MERGED';
-
-/**
- * @extends PolymerElement
- */
-class GrFileListHeader extends KeyboardShortcutMixin(
-    GestureEventListeners(
-        LegacyElementMixin(
-            PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-file-list-header'; }
-  /**
-   * @event expand-diffs
-   */
-
-  /**
-   * @event collapse-diffs
-   */
-
-  /**
-   * @event open-diff-prefs
-   */
-
-  /**
-   * @event open-included-in-dialog
-   */
-
-  /**
-   * @event open-download-dialog
-   */
-
-  /**
-   * @event open-upload-help-dialog
-   */
-
-  static get properties() {
-    return {
-      account: Object,
-      allPatchSets: Array,
-      /** @type {?} */
-      change: Object,
-      changeNum: String,
-      changeUrl: String,
-      changeComments: Object,
-      commitInfo: Object,
-      editMode: Boolean,
-      loggedIn: Boolean,
-      serverConfig: Object,
-      shownFileCount: Number,
-      diffPrefs: Object,
-      diffPrefsDisabled: Boolean,
-      diffViewMode: {
-        type: String,
-        notify: true,
-      },
-      patchNum: String,
-      basePatchNum: String,
-      filesExpanded: String,
-      // Caps the number of files that can be shown and have the 'show diffs' /
-      // 'hide diffs' buttons still be functional.
-      _maxFilesForBulkActions: {
-        type: Number,
-        readOnly: true,
-        value: 225,
-      },
-      _patchsetDescription: {
-        type: String,
-        value: '',
-      },
-      _descriptionReadOnly: {
-        type: Boolean,
-        computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
-      },
-      revisionInfo: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_computePatchSetDescription(change, patchNum)',
-    ];
-  }
-
-  setDiffViewMode(mode) {
-    this.$.modeSelect.setMode(mode);
-  }
-
-  _expandAllDiffs() {
-    this._expanded = true;
-    this.dispatchEvent(new CustomEvent('expand-diffs', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _collapseAllDiffs() {
-    this._expanded = false;
-    this.dispatchEvent(new CustomEvent('collapse-diffs', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _computeExpandedClass(filesExpanded) {
-    const classes = [];
-    if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
-      classes.push('expanded');
-    }
-    if (filesExpanded === GrFileListConstants.FilesExpandedState.SOME ||
-          filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
-      classes.push('openFile');
-    }
-    return classes.join(' ');
-  }
-
-  _computeDescriptionPlaceholder(readOnly) {
-    return (readOnly ? 'No' : 'Add') + ' patchset description';
-  }
-
-  _computeDescriptionReadOnly(loggedIn, change, account) {
-    // Polymer 2: check for undefined
-    if ([loggedIn, change, account].includes(undefined)) {
-      return undefined;
-    }
-
-    return !(loggedIn && (account._account_id === change.owner._account_id));
-  }
-
-  _computePatchSetDescription(change, patchNum) {
-    // Polymer 2: check for undefined
-    if ([change, patchNum].includes(undefined)) {
-      return;
-    }
-
-    const rev = getRevisionByPatchNum(change.revisions, patchNum);
-    this._patchsetDescription = (rev && rev.description) ?
-      rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-  }
-
-  _handleDescriptionRemoved(e) {
-    return this._updateDescription('', e);
-  }
-
-  /**
-   * @param {!Object} revisions The revisions object keyed by revision hashes
-   * @param {?Object} patchSet A revision already fetched from {revisions}
-   * @return {string|undefined} the SHA hash corresponding to the revision.
-   */
-  _getPatchsetHash(revisions, patchSet) {
-    for (const rev in revisions) {
-      if (revisions.hasOwnProperty(rev) &&
-          revisions[rev] === patchSet) {
-        return rev;
-      }
-    }
-  }
-
-  _handleDescriptionChanged(e) {
-    const desc = e.detail.trim();
-    this._updateDescription(desc, e);
-  }
-
-  /**
-   * Update the patchset description with the rest API.
-   *
-   * @param {string} desc
-   * @param {?(Event|Node)} e
-   * @return {!Promise}
-   */
-  _updateDescription(desc, e) {
-    const target = dom(e).rootTarget;
-    if (target) { target.disabled = true; }
-    const rev = getRevisionByPatchNum(this.change.revisions,
-        this.patchNum);
-    const sha = this._getPatchsetHash(this.change.revisions, rev);
-    return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
-        .then(res => {
-          if (res.ok) {
-            if (target) { target.disabled = false; }
-            this.set(['change', 'revisions', sha, 'description'], desc);
-            this._patchsetDescription = desc;
-          }
-        })
-        .catch(err => {
-          if (target) { target.disabled = false; }
-          return;
-        });
-  }
-
-  _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
-    return diffPrefsDisabled || !prefs;
-  }
-
-  _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
-    return shownFileCount <= maxFilesForBulkActions;
-  }
-
-  _handlePatchChange(e) {
-    const {basePatchNum, patchNum} = e.detail;
-    if (patchNumEquals(basePatchNum, this.basePatchNum) &&
-        patchNumEquals(patchNum, this.patchNum)) { return; }
-    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
-  }
-
-  _handlePrefsTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('open-diff-prefs', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _handleIncludedInTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('open-included-in-dialog', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _handleDownloadTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-        new CustomEvent('open-download-dialog', {bubbles: false}));
-  }
-
-  _computeEditModeClass(editMode) {
-    return editMode ? 'editMode' : '';
-  }
-
-  _computePatchInfoClass(patchNum, allPatchSets) {
-    const latestNum = computeLatestPatchNum(allPatchSets);
-    if (patchNumEquals(patchNum, latestNum)) {
-      return '';
-    }
-    return 'patchInfoOldPatchSet';
-  }
-
-  _hideIncludedIn(change) {
-    return change && change.status === MERGED_STATUS ? '' : 'hide';
-  }
-
-  _handleUploadTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-        new CustomEvent('open-upload-help-dialog', {bubbles: false}));
-  }
-
-  _computeUploadHelpContainerClass(change, account) {
-    const changeIsMerged = change && change.status === MERGED_STATUS;
-    const ownerId = change && change.owner && change.owner._account_id ?
-      change.owner._account_id : null;
-    const userId = account && account._account_id;
-    const userIsOwner = ownerId && userId && ownerId === userId;
-    const hideContainer = !userIsOwner || changeIsMerged;
-    return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
-  }
-}
-
-customElements.define(GrFileListHeader.is, GrFileListHeader);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
new file mode 100644
index 0000000..fb91806
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -0,0 +1,403 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../diff/gr-patch-range-select/gr-patch-range-select';
+import '../../edit/gr-edit-controls/gr-edit-controls';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-file-list-header_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  computeLatestPatchNum,
+  getRevisionByPatchNum,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {property, computed, observe, customElement} from '@polymer/decorators';
+import {
+  AccountInfo,
+  ChangeInfo,
+  PatchSetNum,
+  CommitInfo,
+  ServerInfo,
+  DiffPreferencesInfo,
+  RevisionInfo,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {DiffViewMode} from '../../../constants/constants';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ChangeNum} from '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+const MERGED_STATUS = 'MERGED';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list-header': GrFileListHeader;
+  }
+}
+
+export interface GrFileListHeader {
+  $: {
+    modeSelect: GrDiffModeSelector;
+    restAPI: RestApiService & Element;
+    expandBtn: GrButton;
+    collapseBtn: GrButton;
+  };
+}
+
+@customElement('gr-file-list-header')
+export class GrFileListHeader extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * @event expand-diffs
+   */
+
+  /**
+   * @event collapse-diffs
+   */
+
+  /**
+   * @event open-diff-prefs
+   */
+
+  /**
+   * @event open-included-in-dialog
+   */
+
+  /**
+   * @event open-download-dialog
+   */
+
+  /**
+   * @event open-upload-help-dialog
+   */
+
+  @property({type: Object})
+  account: AccountInfo | undefined;
+
+  @property({type: Array})
+  allPatchSets?: PatchSet[];
+
+  @property({type: Object})
+  change: ChangeInfo | undefined;
+
+  @property({type: String})
+  changeNum?: ChangeNum;
+
+  @property({type: String})
+  changeUrl?: string;
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
+  commitInfo?: CommitInfo;
+
+  @property({type: Boolean})
+  editMode?: boolean;
+
+  @property({type: Boolean})
+  loggedIn: boolean | undefined;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Number})
+  shownFileCount?: number;
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Boolean})
+  diffPrefsDisabled?: boolean;
+
+  @property({type: String, notify: true})
+  diffViewMode?: DiffViewMode;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property({type: String})
+  basePatchNum?: PatchSetNum;
+
+  @property({type: String})
+  filesExpanded?: FilesExpandedState;
+
+  // Caps the number of files that can be shown and have the 'show diffs' /
+  // 'hide diffs' buttons still be functional.
+  @property({type: Number})
+  readonly _maxFilesForBulkActions = 225;
+
+  @property({type: String})
+  _patchsetDescription = '';
+
+  @property({type: Object})
+  revisionInfo?: RevisionInfo;
+
+  @computed('loggedIn', 'change', 'account')
+  get _descriptionReadOnly(): boolean {
+    // Polymer 2: check for undefined
+    if (
+      this.loggedIn === undefined ||
+      this.change === undefined ||
+      this.account === undefined
+    ) {
+      return false;
+    }
+
+    return !(
+      this.loggedIn &&
+      this.account._account_id === this.change.owner._account_id
+    );
+  }
+
+  setDiffViewMode(mode: DiffViewMode) {
+    this.$.modeSelect.setMode(mode);
+  }
+
+  _expandAllDiffs() {
+    this.dispatchEvent(
+      new CustomEvent('expand-diffs', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _collapseAllDiffs() {
+    this.dispatchEvent(
+      new CustomEvent('collapse-diffs', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computeExpandedClass(filesExpanded: FilesExpandedState) {
+    const classes = [];
+    if (filesExpanded === FilesExpandedState.ALL) {
+      classes.push('expanded');
+    }
+    if (
+      filesExpanded === FilesExpandedState.SOME ||
+      filesExpanded === FilesExpandedState.ALL
+    ) {
+      classes.push('openFile');
+    }
+    return classes.join(' ');
+  }
+
+  _computeDescriptionPlaceholder(readOnly: boolean) {
+    return (readOnly ? 'No' : 'Add') + ' patchset description';
+  }
+
+  @observe('change', 'patchNum')
+  _computePatchSetDescription(change: ChangeInfo, patchNum: PatchSetNum) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      change.revisions === undefined ||
+      patchNum === undefined
+    ) {
+      return;
+    }
+
+    const rev = getRevisionByPatchNum(
+      Object.values(change.revisions),
+      patchNum
+    );
+    this._patchsetDescription = rev?.description
+      ? rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
+      : '';
+  }
+
+  _handleDescriptionRemoved(e: CustomEvent) {
+    return this._updateDescription('', e);
+  }
+
+  /**
+   * @param revisions The revisions object keyed by revision hashes
+   * @param patchSet A revision already fetched from {revisions}
+   * @return the SHA hash corresponding to the revision.
+   */
+  _getPatchsetHash(
+    revisions: {[revisionId: string]: RevisionInfo},
+    patchSet: RevisionInfo
+  ) {
+    for (const sha of Object.keys(revisions)) {
+      if (revisions[sha] === patchSet) {
+        return sha;
+      }
+    }
+    throw new Error('patchset hash not found');
+  }
+
+  _handleDescriptionChanged(e: CustomEvent) {
+    const desc = e.detail.trim();
+    this._updateDescription(desc, e);
+  }
+
+  /**
+   * Update the patchset description with the rest API.
+   */
+  _updateDescription(desc: string, e: CustomEvent) {
+    if (
+      !this.change ||
+      !this.change.revisions ||
+      !this.patchNum ||
+      !this.changeNum
+    )
+      return;
+    // target can be either gr-editable-label or gr-linked-chip
+    const target = (dom(e) as EventApi).rootTarget as HTMLElement & {
+      disabled: boolean;
+    };
+    if (target) {
+      target.disabled = true;
+    }
+    const rev = getRevisionByPatchNum(
+      Object.values(this.change.revisions),
+      this.patchNum
+    )!;
+    const sha = this._getPatchsetHash(this.change.revisions, rev);
+    return this.$.restAPI
+      .setDescription(this.changeNum, this.patchNum, desc)
+      .then((res: Response) => {
+        if (res.ok) {
+          if (target) {
+            target.disabled = false;
+          }
+          this.set(['change', 'revisions', sha, 'description'], desc);
+          this._patchsetDescription = desc;
+        }
+      })
+      .catch(() => {
+        if (target) {
+          target.disabled = false;
+        }
+        return;
+      });
+  }
+
+  _computePrefsButtonHidden(
+    prefs: DiffPreferencesInfo,
+    diffPrefsDisabled: boolean
+  ) {
+    return diffPrefsDisabled || !prefs;
+  }
+
+  _fileListActionsVisible(
+    shownFileCount: number,
+    maxFilesForBulkActions: number
+  ) {
+    return shownFileCount <= maxFilesForBulkActions;
+  }
+
+  _handlePatchChange(e: CustomEvent) {
+    const {basePatchNum, patchNum} = e.detail;
+    if (
+      (patchNumEquals(basePatchNum, this.basePatchNum) &&
+        patchNumEquals(patchNum, this.patchNum)) ||
+      !this.change
+    ) {
+      return;
+    }
+    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+  }
+
+  _handlePrefsTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('open-diff-prefs', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleIncludedInTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('open-included-in-dialog', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleDownloadTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('open-download-dialog', {bubbles: false})
+    );
+  }
+
+  _computeEditModeClass(editMode?: boolean) {
+    return editMode ? 'editMode' : '';
+  }
+
+  _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
+    const latestNum = computeLatestPatchNum(allPatchSets);
+    if (patchNumEquals(patchNum, latestNum)) {
+      return '';
+    }
+    return 'patchInfoOldPatchSet';
+  }
+
+  _hideIncludedIn(change?: ChangeInfo) {
+    return change?.status === MERGED_STATUS ? '' : 'hide';
+  }
+
+  _handleUploadTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('open-upload-help-dialog', {bubbles: false})
+    );
+  }
+
+  _computeUploadHelpContainerClass(change: ChangeInfo, account: AccountInfo) {
+    const changeIsMerged = change?.status === MERGED_STATUS;
+    const ownerId = change?.owner?._account_id || null;
+    const userId = account && account._account_id;
+    const userIsOwner = ownerId && userId && ownerId === userId;
+    const hideContainer = !userIsOwner || changeIsMerged;
+    return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index dd05b9d..af59616 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -17,9 +17,10 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-file-list-header.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {generateChange} from '../../../test/test-utils.js';
+import 'lodash/lodash.js';
 
 const basicFixture = fixtureFromElement('gr-file-list-header');
 
@@ -61,12 +62,20 @@
   });
 
   test('_computeDescriptionReadOnly', () => {
-    assert.equal(element._computeDescriptionReadOnly(false,
-        {owner: {_account_id: 1}}, {_account_id: 1}), true);
-    assert.equal(element._computeDescriptionReadOnly(true,
-        {owner: {_account_id: 0}}, {_account_id: 1}), true);
-    assert.equal(element._computeDescriptionReadOnly(true,
-        {owner: {_account_id: 1}}, {_account_id: 1}), false);
+    element.loggedIn = false;
+    element.change = {owner: {_account_id: 1}};
+    element.account = {_account_id: 1};
+    assert.equal(element._descriptionReadOnly, true);
+
+    element.loggedIn = true;
+    element.change = {owner: {_account_id: 0}};
+    element.account = {_account_id: 1};
+    assert.equal(element._descriptionReadOnly, true);
+
+    element.loggedIn = true;
+    element.change = {owner: {_account_id: 1}};
+    element.account = {_account_id: 1};
+    assert.equal(element._descriptionReadOnly, false);
   });
 
   test('_computeDescriptionPlaceholder', () => {
@@ -96,11 +105,13 @@
       owner: {_account_id: 1},
     };
     element.account = {_account_id: 1};
+    element.owner = {_account_id: 1};
     element.loggedIn = true;
 
     flushAsynchronousOperations();
 
     // The element has a description, so the account chip should be visible
+    element.owner = {_account_id: 1};
     // and the description label should not exist.
     const chip = element.root.querySelector('#descriptionChip');
     let label = element.root.querySelector('#descriptionLabel');
@@ -182,13 +193,13 @@
     const actions = element.shadowRoot
         .querySelector('.fileViewActions');
     assert.equal(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    element.filesExpanded = FilesExpandedState.SOME;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    element.filesExpanded = FilesExpandedState.ALL;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    element.filesExpanded = FilesExpandedState.NONE;
     flushAsynchronousOperations();
     assert.equal(getComputedStyle(actions).display, 'none');
   });
@@ -200,15 +211,15 @@
     const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    element.filesExpanded = FilesExpandedState.SOME;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    element.filesExpanded = FilesExpandedState.ALL;
     flushAsynchronousOperations();
     assert.equal(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    element.filesExpanded = FilesExpandedState.NONE;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index aaf0205..bf45eb6 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -34,7 +34,7 @@
 import {htmlTemplate} from './gr-file-list_html.js';
 import {asyncForeach} from '../../../utils/async-util.js';
 import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
@@ -42,7 +42,6 @@
 import {appContext} from '../../../services/app-context.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {descendedFromClass} from '../../../utils/dom-util.js';
-import {getRevisionByPatchNum} from '../../../utils/patch-set-util.js';
 import {
   addUnmodifiedFiles,
   computeDisplayPath,
@@ -51,8 +50,6 @@
   specialFilePathCompare,
 } from '../../../utils/path-list-util.js';
 
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
 const WARN_SHOW_ALL_THRESHOLD = 1000;
 const LOADING_DEBOUNCE_INTERVAL = 100;
 
@@ -139,7 +136,7 @@
       },
       filesExpanded: {
         type: String,
-        value: GrFileListConstants.FilesExpandedState.NONE,
+        value: FilesExpandedState.NONE,
         notify: true,
       },
       _filesByPath: Object,
@@ -1161,17 +1158,6 @@
     this.numFilesShown = this._files.length;
   }
 
-  _computePatchSetDescription(revisions, patchNum) {
-    // Polymer 2: check for undefined
-    if ([revisions, patchNum].includes(undefined)) {
-      return '';
-    }
-
-    const rev = getRevisionByPatchNum(revisions, patchNum);
-    return (rev && rev.description) ?
-      rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-  }
-
   /**
    * Get a descriptive label for use in the status indicator's tooltip and
    * ARIA label.
@@ -1210,11 +1196,11 @@
 
   _computeExpandedFiles(expandedCount, totalCount) {
     if (expandedCount === 0) {
-      return GrFileListConstants.FilesExpandedState.NONE;
+      return FilesExpandedState.NONE;
     } else if (expandedCount === totalCount) {
-      return GrFileListConstants.FilesExpandedState.ALL;
+      return FilesExpandedState.ALL;
     }
-    return GrFileListConstants.FilesExpandedState.SOME;
+    return FilesExpandedState.SOME;
   }
 
   /**
@@ -1575,7 +1561,7 @@
    * @return {boolean}
    */
   _noDiffsExpanded() {
-    return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
+    return this.filesExpanded === FilesExpandedState.NONE;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index aaa4499..0343b39 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -21,7 +21,7 @@
 import './gr-file-list.js';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -1107,23 +1107,23 @@
       };
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
+          FilesExpandedState.NONE);
       element.push('_expandedFiles', {path: 'baz.bar'});
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.SOME);
+          FilesExpandedState.SOME);
       element.push('_expandedFiles', {path: 'foo.bar'});
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
+          FilesExpandedState.ALL);
       element.collapseAllDiffs();
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
+          FilesExpandedState.NONE);
       element.expandAllDiffs();
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
+          FilesExpandedState.ALL);
     });
 
     test('_renderInOrder', done => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index c1ab0280..d7a41f1 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -657,14 +657,14 @@
     }
     if (section === FocusTarget.BODY) {
       const textarea = this.$.textarea;
-      textarea.async(textarea.getNativeTextarea()
-          .focus.bind(textarea.getNativeTextarea()));
+      textarea.async(() => textarea.getNativeTextarea()
+          .focus());
     } else if (section === FocusTarget.REVIEWERS) {
       const reviewerEntry = this.$.reviewers.focusStart;
-      reviewerEntry.async(reviewerEntry.focus);
+      reviewerEntry.async(() => reviewerEntry.focus());
     } else if (section === FocusTarget.CCS) {
       const ccEntry = this.$.ccs.focusStart;
-      ccEntry.async(ccEntry.focus);
+      ccEntry.async(() => ccEntry.focus());
     }
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index dad7d4b..6f1bc60 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -160,6 +160,8 @@
    */
   QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
 
+  CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
+
   // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
   CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
   CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
@@ -1006,6 +1008,8 @@
 
     this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
 
+    this._mapRoute(RoutePattern.CHANGE_ID_QUERY, '_handleChangeIdQueryRoute');
+
     this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
 
     this._mapRoute(
@@ -1498,6 +1502,16 @@
     });
   }
 
+  _handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
+    // TODO(pcc): This will need to indicate that this was a change ID query if
+    // standard queries gain the ability to search places like commit messages
+    // for change IDs.
+    this._setParams({
+      view: GerritNav.View.SEARCH,
+      query: data.params[0],
+    });
+  }
+
   _handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
     this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
   }
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index a931aac..5ee58c9 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -182,6 +182,7 @@
       '_handleBranchListFilterOffsetRoute',
       '_handleBranchListFilterRoute',
       '_handleBranchListOffsetRoute',
+      '_handleChangeIdQueryRoute',
       '_handleChangeNumberLegacyRoute',
       '_handleChangeRoute',
       '_handleCommentRoute',
@@ -739,6 +740,14 @@
       });
     });
 
+    test('_handleChangeIdQueryRoute', () => {
+      const data = {params: ['I0123456789abcdef0123456789abcdef01234567']};
+      assertDataToParams(data, '_handleChangeIdQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'I0123456789abcdef0123456789abcdef01234567',
+      });
+    });
+
     suite('_handleRegisterRoute', () => {
       test('happy path', () => {
         const ctx = {params: ['/foo/bar']};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
index f3de371..15264ea 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -52,8 +52,7 @@
     // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
     // column limit.
     td.setAttribute('colspan', '4');
-    const endpoint = this._createElement('gr-endpoint-decorator');
-    const endpointDomApi = endpoint;
+    const endpointDomApi = this._createElement('gr-endpoint-decorator');
     endpointDomApi.setAttribute('name', 'image-diff');
     endpointDomApi.appendChild(
       this._createEndpointParam('baseImage', this._baseImage)
@@ -61,7 +60,7 @@
     endpointDomApi.appendChild(
       this._createEndpointParam('revisionImage', this._revisionImage)
     );
-    td.appendChild(endpoint);
+    td.appendChild(endpointDomApi);
     tr.appendChild(td);
     tbody.appendChild(tr);
     return tbody;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 73ad899..314c322 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -52,7 +52,6 @@
   isMagicPath, specialFilePathCompare,
 } from '../../../utils/path-list-util.js';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
-import {KnownExperimentId} from '../../../services/flags/flags.js';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -308,8 +307,6 @@
   /** @override */
   attached() {
     super.attached();
-    this._isChangeCommentsLinkExperimentEnabled = this.flagsService
-        .isEnabled(KnownExperimentId.PATCHSET_CHOICE_FOR_COMMENT_LINKS);
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
@@ -960,8 +957,7 @@
                 this._change, this._path, this._patchRange.basePatchNum);
             return;
           }
-          if (this._isChangeCommentsLinkExperimentEnabled &&
-            !!value.commentLink) {
+          if (value.commentLink) {
             this._displayToasts();
           }
           // If the blame was loaded for a previous file and user navigates to
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index f0b0dcd..e4dd52e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -207,7 +207,6 @@
           sinon.spy(element, '_paramsChanged');
           sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
               generateChange({revisionsCount: 11})));
-          element._isChangeCommentsLinkExperimentEnabled = true;
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -269,7 +268,6 @@
       sinon.spy(element, '_paramsChanged');
       sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
           generateChange({revisionsCount: 11})));
-      element._isChangeCommentsLinkExperimentEnabled = true;
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 3e5cfb5..b6e1885 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -405,7 +405,7 @@
     addFrontSpace?: boolean
   ) {
     const rev = getRevisionByPatchNum(revisions, patchNum);
-    return rev && rev.description
+    return rev?.description
       ? (addFrontSpace ? ' ' : '') +
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
       : '';
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.js
index 30ff123..07d37fa 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.js
@@ -35,7 +35,6 @@
 import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api.js';
 import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api.js';
 import {GrEditConstants} from './edit/gr-edit-constants.js';
-import {GrFileListConstants} from './change/gr-file-list-constants.js';
 import {GrDomHooksManager, GrDomHook} from './plugins/gr-dom-hooks/gr-dom-hooks.js';
 import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator.js';
 import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api.js';
@@ -92,7 +91,6 @@
   window.GrChangeActionsInterface = GrChangeActionsInterface;
   window.GrChangeReplyInterface = GrChangeReplyInterface;
   window.GrEditConstants = GrEditConstants;
-  window.GrFileListConstants = GrFileListConstants;
   window.GrDomHooksManager = GrDomHooksManager;
   window.GrDomHook = GrDomHook;
   window.GrEtagDecorator = GrEtagDecorator;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 674297d..3a83729 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -114,6 +114,7 @@
     }
     const expectProperties = this._getEndpointParams().map(paramEl => {
       const helper = plugin.attributeHelper(paramEl);
+      // TODO: this should be replaced by accessing the property directly
       const paramName = paramEl.getAttribute('name');
       if (!paramName) throw Error('plugin endpoint parameter missing a name');
       return helper
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index 41964d5..9e9f348 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -29,7 +29,7 @@
 export class GrEndpointParam extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
 ) {
-  @property({type: String})
+  @property({type: String, reflectToAttribute: true})
   name = '';
 
   @property({
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index 3b7dbfe..5de7a66 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -29,7 +29,6 @@
 import {appContext} from '../../../services/app-context.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {computeDisplayPath} from '../../../utils/path-list-util.js';
-import {KnownExperimentId} from '../../../services/flags/flags.js';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -192,8 +191,6 @@
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
-    this._isChangeCommentsLinkExperimentEnabled = this.flagsService
-        .isEnabled(KnownExperimentId.PATCHSET_CHOICE_FOR_COMMENT_LINKS);
     this._setInitialExpandedState();
   }
 
@@ -228,8 +225,7 @@
   }
 
   _getDiffUrlForPath(path) {
-    if (!this._isChangeCommentsLinkExperimentEnabled ||
-      this.comments[0].__draft) {
+    if (this.comments[0].__draft) {
       return GerritNav.getUrlForDiffById(this.changeNum,
           this.projectName, path, this.patchNum);
     }
@@ -238,8 +234,7 @@
   }
 
   _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
-    if (!this._isChangeCommentsLinkExperimentEnabled ||
-      (this.comments.length && this.comments[0].side === 'PARENT') ||
+    if ((this.comments.length && this.comments[0].side === 'PARENT') ||
       this.comments[0].__draft) {
       return GerritNav.getUrlForDiffById(changeNum,
           projectName, path, patchNum, null, this.lineNum);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
index ab93cb6..a25a4c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -186,7 +186,6 @@
       element.lineNum = 5;
       element.comments = [{id: 'comment_id'}];
       element.showFilePath = true;
-      element._isChangeCommentsLinkExperimentEnabled = true;
       flushAsynchronousOperations();
       assert.isOk(element.shadowRoot
           .querySelector('.pathInfo'));
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
deleted file mode 100644
index a6020cf..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/gr-voting-styles.js';
-import '../../../styles/shared-styles.js';
-import '../gr-account-label/gr-account-label.js';
-import '../gr-account-link/gr-account-link.js';
-import '../gr-button/gr-button.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-label/gr-label.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-label-info_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/** @extends PolymerElement */
-class GrLabelInfo extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-label-info'; }
-
-  static get properties() {
-    return {
-      labelInfo: Object,
-      label: String,
-      /** @type {?} */
-      change: Object,
-      account: Object,
-      mutable: Boolean,
-    };
-  }
-
-  /**
-   * @param {!Object} labelInfo
-   * @param {!Object} account
-   * @param {Object} changeLabelsRecord not used, but added as a parameter in
-   *    order to trigger computation when a label is removed from the change.
-   */
-  _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
-    const result = [];
-    if (!labelInfo || !account) { return result; }
-    if (!labelInfo.values) {
-      if (labelInfo.rejected || labelInfo.approved) {
-        const ok = labelInfo.approved || !labelInfo.rejected;
-        return [{
-          value: ok ? '👍️' : '👎️',
-          className: ok ? 'positive' : 'negative',
-          account: ok ? labelInfo.approved : labelInfo.rejected,
-        }];
-      }
-      return result;
-    }
-    // Sort votes by positivity.
-    const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
-    const values = Object.keys(labelInfo.values);
-    for (const label of votes) {
-      if (label.value && label.value != labelInfo.default_value) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          if (parseInt(label.value, 10) ===
-              parseInt(values[values.length - 1], 10)) {
-            labelClassName = 'max';
-          } else {
-            labelClassName = 'positive';
-          }
-        } else if (label.value < 0) {
-          if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
-            labelClassName = 'min';
-          } else {
-            labelClassName = 'negative';
-          }
-        }
-        const formattedLabel = {
-          value: labelValPrefix + label.value,
-          className: labelClassName,
-          account: label,
-        };
-        if (label._account_id === account._account_id) {
-          // Put self-votes at the top.
-          result.unshift(formattedLabel);
-        } else {
-          result.push(formattedLabel);
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
-   * A user is able to delete a vote iff the mutable property is true and the
-   * reviewer that left the vote exists in the list of removable_reviewers
-   * received from the backend.
-   *
-   * @param {!Object} reviewer An object describing the reviewer that left the
-   *     vote.
-   * @param {boolean} mutable
-   * @param {!Object} change
-   */
-  _computeDeleteClass(reviewer, mutable, change) {
-    if (!mutable || !change || !change.removable_reviewers) {
-      return 'hidden';
-    }
-    const removable = change.removable_reviewers;
-    if (removable.find(r => r._account_id === reviewer._account_id)) {
-      return '';
-    }
-    return 'hidden';
-  }
-
-  /**
-   * Closure annotation for Polymer.prototype.splice is off.
-   * For now, suppressing annotations.
-   *
-   * @suppress {checkTypes} */
-  _onDeleteVote(e) {
-    e.preventDefault();
-    let target = dom(e).rootTarget;
-    while (!target.classList.contains('deleteBtn')) {
-      if (!target.parentElement) { return; }
-      target = target.parentElement;
-    }
-
-    target.disabled = true;
-    const accountID = parseInt(target.getAttribute('data-account-id'), 10);
-    this._xhrPromise =
-        this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
-            .then(response => {
-              target.disabled = false;
-              if (!response.ok) { return; }
-              GerritNav.navigateToChange(this.change);
-            })
-            .catch(err => {
-              target.disabled = false;
-              return;
-            });
-  }
-
-  _computeValueTooltip(labelInfo, score) {
-    if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
-      return '';
-    }
-    return labelInfo.values[score];
-  }
-
-  /**
-   * @param {!Object} labelInfo
-   * @param {Object} changeLabelsRecord not used, but added as a parameter in
-   *    order to trigger computation when a label is removed from the change.
-   */
-  _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
-    if (labelInfo &&
-        !labelInfo.values && (labelInfo.rejected || labelInfo.approved)) {
-      return 'hidden';
-    }
-
-    if (labelInfo && labelInfo.all) {
-      for (const label of labelInfo.all) {
-        if (label.value && label.value != labelInfo.default_value) {
-          return 'hidden';
-        }
-      }
-    }
-    return '';
-  }
-}
-
-customElements.define(GrLabelInfo.is, GrLabelInfo);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
new file mode 100644
index 0000000..b6e664e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -0,0 +1,262 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/gr-voting-styles';
+import '../../../styles/shared-styles';
+import '../gr-account-label/gr-account-label';
+import '../gr-account-link/gr-account-link';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import '../gr-label/gr-label';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-label-info_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  AccountInfo,
+  LabelInfo,
+  DetailedLabelInfo,
+  QuickLabelInfo,
+  ApprovalInfo,
+  AccountId,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../gr-button/gr-button';
+
+export interface GrLabelInfo {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-label-info': GrLabelInfo;
+  }
+}
+
+enum LabelClassName {
+  NEGATIVE = 'negative',
+  POSITIVE = 'positive',
+  MIN = 'min',
+  MAX = 'max',
+}
+
+interface FormattedLabel {
+  className?: LabelClassName;
+  account: ApprovalInfo;
+  value: string;
+}
+
+// type guard to check if label is QuickLabelInfo
+function isQuickLabelInfo(label: LabelInfo): label is QuickLabelInfo {
+  return !(label as DetailedLabelInfo).values;
+}
+
+@customElement('gr-label-info')
+export class GrLabelInfo extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
+  @property({type: String})
+  label = '';
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Boolean})
+  mutable = false;
+
+  // TODO(TS): not used, remove later
+  _xhrPromise?: Promise<void>;
+
+  /**
+   * This method also listens on change.labels.*,
+   * to trigger computation when a label is removed from the change.
+   */
+  _mapLabelInfo(labelInfo: LabelInfo, account: AccountInfo) {
+    const result: FormattedLabel[] = [];
+    if (!labelInfo || !account) {
+      return result;
+    }
+    if (isQuickLabelInfo(labelInfo)) {
+      if (labelInfo.rejected || labelInfo.approved) {
+        const ok = labelInfo.approved || !labelInfo.rejected;
+        return [
+          {
+            value: ok ? '👍️' : '👎️',
+            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
+            account: ok ? labelInfo.approved : labelInfo.rejected,
+          },
+        ];
+      }
+      return result;
+    }
+
+    // Sort votes by positivity.
+    // TODO(TS): maybe mark value as required if always present
+    const votes = (labelInfo.all || []).sort(
+      (a, b) => (a.value || 0) - (b.value || 0)
+    );
+    const values = Object.keys(labelInfo.values || {});
+    for (const label of votes) {
+      if (label.value && label.value !== labelInfo.default_value) {
+        let labelClassName;
+        let labelValPrefix = '';
+        if (label.value > 0) {
+          labelValPrefix = '+';
+          if (
+            parseInt(`${label.value}`, 10) ===
+            parseInt(values[values.length - 1], 10)
+          ) {
+            labelClassName = LabelClassName.MAX;
+          } else {
+            labelClassName = LabelClassName.POSITIVE;
+          }
+        } else if (label.value < 0) {
+          if (parseInt(`${label.value}`, 10) === parseInt(values[0], 10)) {
+            labelClassName = LabelClassName.MIN;
+          } else {
+            labelClassName = LabelClassName.NEGATIVE;
+          }
+        }
+        const formattedLabel = {
+          value: `${labelValPrefix}${label.value}`,
+          className: labelClassName,
+          account: label,
+        };
+        if (label._account_id === account._account_id) {
+          // Put self-votes at the top.
+          result.unshift(formattedLabel);
+        } else {
+          result.push(formattedLabel);
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * A user is able to delete a vote iff the mutable property is true and the
+   * reviewer that left the vote exists in the list of removable_reviewers
+   * received from the backend.
+   *
+   * @param reviewer An object describing the reviewer that left the
+   *     vote.
+   */
+  _computeDeleteClass(
+    reviewer: ApprovalInfo,
+    mutable: boolean,
+    change: ChangeInfo
+  ) {
+    if (!mutable || !change || !change.removable_reviewers) {
+      return 'hidden';
+    }
+    const removable = change.removable_reviewers;
+    if (removable.find(r => r._account_id === reviewer._account_id)) {
+      return '';
+    }
+    return 'hidden';
+  }
+
+  /**
+   * Closure annotation for Polymer.prototype.splice is off.
+   * For now, suppressing annotations.
+   */
+  _onDeleteVote(e: MouseEvent) {
+    if (!this.change) return;
+
+    e.preventDefault();
+    let target = (dom(e) as EventApi).rootTarget as GrButton;
+    while (!target.classList.contains('deleteBtn')) {
+      if (!target.parentElement) {
+        return;
+      }
+      target = target.parentElement as GrButton;
+    }
+
+    target.disabled = true;
+    const accountID = parseInt(
+      `${target.getAttribute('data-account-id')}`,
+      10
+    ) as AccountId;
+    this._xhrPromise = this.$.restAPI
+      .deleteVote(this.change._number, accountID, this.label)
+      .then(response => {
+        target.disabled = false;
+        if (!response.ok) {
+          return;
+        }
+        if (this.change) {
+          GerritNav.navigateToChange(this.change);
+        }
+      })
+      .catch(err => {
+        console.warn(err);
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _computeValueTooltip(labelInfo: LabelInfo, score: string) {
+    if (
+      !labelInfo ||
+      isQuickLabelInfo(labelInfo) ||
+      !labelInfo.values?.[score]
+    ) {
+      return '';
+    }
+    return labelInfo.values[score];
+  }
+
+  /**
+   * This method also listens change.labels.* in
+   * order to trigger computation when a label is removed from the change.
+   */
+  _computeShowPlaceholder(labelInfo: LabelInfo) {
+    if (
+      labelInfo &&
+      isQuickLabelInfo(labelInfo) &&
+      (labelInfo.rejected || labelInfo.approved)
+    ) {
+      return 'hidden';
+    }
+
+    // TODO(TS): might replace with hasOwnProperty instead
+    if (labelInfo && (labelInfo as DetailedLabelInfo).all) {
+      for (const label of (labelInfo as DetailedLabelInfo).all || []) {
+        if (label.value && label.value !== labelInfo.default_value) {
+          return 'hidden';
+        }
+      }
+    }
+    return '';
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index 0fbaba2..b936237 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -21,7 +21,7 @@
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
-suite('gr-account-link tests', () => {
+suite('gr-label-info tests', () => {
   let element;
 
   setup(() => {
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index c6555d0..9fb2882 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -585,4 +585,14 @@
   ): Promise<void>;
   setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void>;
   getAvatarChangeUrl(): Promise<string | undefined>;
+  setDescription(
+    changeNum: ChangeNum,
+    patchNum: PatchSetNum,
+    desc: string
+  ): Promise<Response>;
+  deleteVote(
+    changeNum: ChangeNum,
+    account: AccountId,
+    label: string
+  ): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index f4ffcb2..52adb7b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -140,7 +140,7 @@
  * The LabelInfo entity contains information about a label on a change, always corresponding to the current patch set.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
  */
-type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
+export type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
 
 interface LabelCommonInfo {
   optional?: boolean; // not set if false
@@ -159,6 +159,7 @@
 export interface DetailedLabelInfo extends LabelCommonInfo {
   all?: ApprovalInfo[];
   values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  default_value?: number;
 }
 
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index ba95690..298ae9d 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -133,7 +133,7 @@
 }
 
 export function changeIsOpen(change?: Change) {
-  return change && change.status === ChangeStatus.NEW;
+  return change?.status === ChangeStatus.NEW;
 }
 
 export function changeStatuses(
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 0cef352..ff55e1f 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -91,7 +91,7 @@
   revisions: RevisionInfo[],
   patchNum: PatchSetNum
 ) {
-  for (const rev of Object.values(revisions || {})) {
+  for (const rev of revisions) {
     if (patchNumEquals(rev._number, patchNum)) {
       return rev;
     }
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 2b788a1..12b3516 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -482,7 +482,7 @@
 
 // Any path prefixes that should resolve to index.html.
 var (
-	fePaths    = []string{"/q/", "/c/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
+	fePaths    = []string{"/q/", "/c/", "/id/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
 	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
 )
 
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index d797be3..4f1a3f7 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -110,6 +110,25 @@
   fi
 }
 
+# gerrit.reviewUrl causes us to create Link instead of Change-Id.
+function test_link {
+  cat << EOF > input
+bla bla
+EOF
+
+  git config gerrit.reviewUrl https://myhost/
+  ${hook} input || fail "failed hook execution"
+  git config --unset gerrit.reviewUrl
+  found=$(grep -c '^Change-Id' input || true)
+  if [[ "${found}" != "0" ]]; then
+    fail "got ${found} Change-Ids, want 0"
+  fi
+  found=$(grep -c '^Link: https://myhost/id/I' input || true)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Link footers, want 1"
+  fi
+}
+
 # Change-Id goes after existing trailers.
 function test_at_end {
   cat << EOF > input
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 2901232..3a40d22 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -48,12 +48,23 @@
   exit 1
 fi
 
-# Avoid the --in-place option which only appeared in Git 2.8
-# Avoid the --if-exists option which only appeared in Git 2.15
-if ! git -c trailer.ifexists=doNothing interpret-trailers \
-      --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
-  echo "cannot insert change-id line in $1"
-  exit 1
+reviewurl="$(git config --get gerrit.reviewUrl)"
+if test -n "${reviewurl}" ; then
+  if ! git interpret-trailers --parse < "$1" | grep -q '^Link:.*/id/I[0-9a-f]\{40\}$' ; then
+    if ! git interpret-trailers \
+          --trailer "Link: ${reviewurl%/}/id/I${random}" < "$1" > "${dest}" ; then
+      echo "cannot insert link footer in $1"
+      exit 1
+    fi
+  fi
+else
+  # Avoid the --in-place option which only appeared in Git 2.8
+  # Avoid the --if-exists option which only appeared in Git 2.15
+  if ! git -c trailer.ifexists=doNothing interpret-trailers \
+        --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
+    echo "cannot insert change-id line in $1"
+    exit 1
+  fi
 fi
 
 if ! mv "${dest}" "$1" ; then