Allow a Link trailer to be used as an alternative change ID trailer

In some projects it may be desirable for the trailer to contain a link to
the Gerrit review page so that it is convenient to access the review page
starting from the commit message. The Link trailer is a standard trailer
used for inserting links in the commit message, which has been adopted by
the Linux kernel among other projects. For example, the Linux kernel has a
policy about linking to the mailing list archive with Link trailers:
https://www.kernel.org/doc/html/latest/maintainer/configure-git.html#creating-commit-links-to-lore-kernel-org

This change makes Gerrit interoperate well with the Link trailer. Specifically,
it teaches the Gerrit server to recognize trailers of the form:

    Link: https://gerrit-review.googlesource.com/id/I78e884a944cedb5144f661a057e4829c8f84e933

as well as the existing Change-Id trailer, teaches the server to recognize
/id/ as a search prefix for change IDs and modifies the commit-msg hook
to optionally add a Link trailer (using a server URL provided in the
property gerrit.reviewUrl) instead of the Change-Id trailer.

Change-Id: I78e884a944cedb5144f661a057e4829c8f84e933
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/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/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/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/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 22772e7..bd70de1 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;
@@ -2092,7 +2095,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(
@@ -2184,7 +2187,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 {
@@ -3323,7 +3326,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/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/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 1094809..38f7c06 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;
@@ -1316,7 +1317,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/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 3ffd819..5261426 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+)\/?/,
@@ -1018,6 +1020,8 @@
 
     this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
 
+    this._mapRoute(RoutePattern.CHANGE_ID_QUERY, '_handleChangeIdQueryRoute');
+
     this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
 
     this._mapRoute(
@@ -1510,6 +1514,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/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