Merge "Introduce a diff model"
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 9745fc5..66ffa42 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -185,6 +185,21 @@
     return ref.startsWith(REFS_CHANGES);
   }
 
+  /** True if the provided ref is in {@code refs/sequences/*}. */
+  public static boolean isSequenceRef(String ref) {
+    return ref.startsWith(REFS_SEQUENCES);
+  }
+
+  /** True if the provided ref is in {@code refs/tags/*}. */
+  public static boolean isTagRef(String ref) {
+    return ref.startsWith(REFS_TAGS);
+  }
+
+  /** True if the provided ref is {@link REFS_EXTERNAL_IDS}. */
+  public static boolean isExternalIdRef(String ref) {
+    return REFS_EXTERNAL_IDS.equals(ref);
+  }
+
   public static String refsGroups(AccountGroup.UUID groupUuid) {
     return REFS_GROUPS + shardUuid(groupUuid.get());
   }
@@ -330,6 +345,21 @@
     return REFS_CONFIG.equals(ref);
   }
 
+  /** Whether the ref is the version branch, i.e. {@code refs/meta/version}. */
+  public static boolean isVersionRef(String ref) {
+    return REFS_VERSION.equals(ref);
+  }
+
+  /** Whether the ref is an auto-merge ref. */
+  public static boolean isAutoMergeRef(String ref) {
+    return ref.startsWith(REFS_CACHE_AUTOMERGE);
+  }
+
+  /** Whether the ref is an reject commit ref, i.e. {@code refs/meta/reject-commits} */
+  public static boolean isRejectCommitsRef(String ref) {
+    return REFS_REJECT_COMMITS.equals(ref);
+  }
+
   /**
    * Whether the ref is managed by Gerrit. Covers all Gerrit-internal refs like refs/cache-automerge
    * and refs/meta as well as refs/changes. Does not cover user-created refs like branches or custom
diff --git a/java/com/google/gerrit/server/update/context/RefUpdateContext.java b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
new file mode 100644
index 0000000..d1c5ff8
--- /dev/null
+++ b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update.context;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Passes additional information about an operation to the {@link BatchRefUpdate#execute} method.
+ *
+ * <p>To pass the additional information {@link RefUpdateContext}, wraps a code into an open
+ * RefUpdateContext, e.g.:
+ *
+ * <pre>{@code
+ * try(RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.CHANGE_MODIFICATION)) {
+ *   ...
+ *   // some code which modifies a ref using BatchRefUpdate.execute method
+ * }
+ * }</pre>
+ *
+ * When the {@link BatchRefUpdate#execute} method is executed, it can get all opened contexts and
+ * use it for an additional actions, e.g. it can put it in the reflog.
+ *
+ * <p>The information provided by this class is used internally in google.
+ *
+ * <p>The InMemoryRepositoryManager file makes some validation to ensure that RefUpdateContext is
+ * used correctly within the code (see thee validateRefUpdateContext method).
+ *
+ * <p>The class includes only operations from open-source gerrit and can be extended (see {@link
+ * TestActionRefUpdateContext} for example how to extend it).
+ */
+public class RefUpdateContext implements AutoCloseable {
+  private static final ThreadLocal<Deque<RefUpdateContext>> current = new ThreadLocal<>();
+
+  /**
+   * List of possible ref-update types.
+   *
+   * <p>Items in this enum are not fine-grained; different actions are shared the same type (e.g.
+   * {@link #CHANGE_MODIFICATION} includes posting comments, change edits and attention set update).
+   *
+   * <p>It is expected, that each type of operation should include only specific ref(s); check the
+   * validateRefUpdateContext in InMemoryRepositoryManager for relation between RefUpdateType and
+   * ref name.
+   */
+  public enum RefUpdateType {
+    /**
+     * Indicates that the context is implemented as a descendant of the {@link RefUpdateContext} .
+     *
+     * <p>The {@link #getUpdateType()} returns this type for all descendant of {@link
+     * RefUpdateContext}. This type is never returned if the context is exactly {@link
+     * RefUpdateContext}.
+     */
+    OTHER,
+    /**
+     * A ref is updated as a part of change-related operation.
+     *
+     * <p>This covers multiple different cases - creating and uploading changes and patchsets,
+     * comments operations, change edits, etc...
+     */
+    CHANGE_MODIFICATION,
+    /** A ref is updated during merge-change operation. */
+    MERGE_CHANGE,
+    /** A ref is updated as a part of a repo sequence operation. */
+    REPO_SEQ,
+    /** A ref is updated as a part of a repo initialization. */
+    INIT_REPO,
+    /** A ref is udpated as a part of gpg keys modification. */
+    GPG_KEYS_MODIFICATION,
+    /** A ref is updated as a part of group(s) update */
+    GROUPS_UPDATE,
+    /** A ref is updated as a part of account(s) update. */
+    ACCOUNTS_UPDATE,
+    /** A ref is updated as a part of direct push. */
+    DIRECT_PUSH,
+    /** A ref is updated as a part of explicit branch update operation. */
+    BRANCH_MODIFICATION,
+    /** A ref is updated as a part of explicit tag update operation. */
+    TAG_MODIFICATION,
+    /**
+     * A tag is updated as a part of an offline operation.
+     *
+     * <p>Offline operation - an operation which is executed separately from the gerrit server and
+     * can't be triggered by any gerrit API. E.g. schema update.
+     */
+    OFFLINE_OPERATION,
+    /** A tag is updated as a part of an update-superproject flow. */
+    UPDATE_SUPERPROJECT,
+    /** A ref is updated as a part of explicit HEAD update operation. */
+    HEAD_MODIFICATION,
+    /** A ref is updated as a part of versioned meta data change. */
+    VERSIONED_META_DATA_CHANGE,
+    /** A ref is updated as a part of commit-ban operation. */
+    BAN_COMMIT
+  }
+
+  /** Opens a provided context. */
+  protected static <T extends RefUpdateContext> T open(T ctx) {
+    getCurrent().addLast(ctx);
+    return ctx;
+  }
+
+  /** Opens a context of a give type. */
+  public static RefUpdateContext open(RefUpdateType updateType) {
+    checkArgument(updateType != RefUpdateType.OTHER, "The OTHER type is for internal use only.");
+    return open(new RefUpdateContext(updateType));
+  }
+
+  /** Returns the list of opened contexts; the first element is the outermost context. */
+  public static ImmutableList<RefUpdateContext> getOpenedContexts() {
+    return ImmutableList.copyOf(getCurrent());
+  }
+
+  /** Checks if there is an open context of the given type. */
+  public static boolean hasOpen(RefUpdateType type) {
+    return getCurrent().stream().anyMatch(ctx -> ctx.getUpdateType() == type);
+  }
+
+  private final RefUpdateType updateType;
+
+  private RefUpdateContext(RefUpdateType updateType) {
+    this.updateType = updateType;
+  }
+
+  protected RefUpdateContext() {
+    this(RefUpdateType.OTHER);
+  }
+
+  protected static final Deque<RefUpdateContext> getCurrent() {
+    Deque<RefUpdateContext> result = current.get();
+    if (result == null) {
+      result = new ArrayDeque<>();
+      current.set(result);
+    }
+    return result;
+  }
+
+  /**
+   * Returns the type of {@link RefUpdateContext}.
+   *
+   * <p>For descendants, always return {@link RefUpdateType#OTHER}
+   */
+  public final RefUpdateType getUpdateType() {
+    return updateType;
+  }
+
+  /** Closes the current context. */
+  @Override
+  public void close() {
+    Deque<RefUpdateContext> openedContexts = getCurrent();
+    checkState(
+        openedContexts.peekLast() == this, "The current context is different from this context.");
+    openedContexts.removeLast();
+  }
+}
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index e5234fe..fb9e64e 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -5,7 +5,10 @@
     testonly = True,
     srcs = glob(
         ["**/*.java"],
-        exclude = ["AssertableExecutorService.java"],
+        exclude = [
+            "AssertableExecutorService.java",
+            "TestActionRefUpdateContext.java",
+        ],
     ),
     visibility = ["//visibility:public"],
     exports = [
@@ -40,6 +43,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
         "//lib:h2",
         "//lib:jgit",
@@ -66,3 +70,14 @@
         "//lib/truth",
     ],
 )
+
+java_library(
+    name = "test-ref-update-context",
+    testonly = True,
+    srcs = ["TestActionRefUpdateContext.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//lib/errorprone:annotations",
+    ],
+)
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 2051ae3..0f70103 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -14,20 +14,55 @@
 
 package com.google.gerrit.testing;
 
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BAN_COMMIT;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GPG_KEYS_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.HEAD_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OFFLINE_OPERATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.REPO_SEQ;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.VERSIONED_META_DATA_CHANGE;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.inject.Inject;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.NavigableSet;
+import java.util.Optional;
+import java.util.function.Predicate;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.dfs.DfsObjDatabase;
+import org.eclipse.jgit.internal.storage.dfs.DfsReftableBatchRefUpdate;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Repository manager that uses in-memory repositories. */
 public class InMemoryRepositoryManager implements GitRepositoryManager {
@@ -56,6 +91,141 @@
       setPerformsAtomicTransactions(true);
     }
 
+    /** Validates that a given ref is updated within the expected context. */
+    private static class RefUpdateContextValidator {
+      /**
+       * A configured singleton for ref context validation.
+       *
+       * <p>Each ref must match no more than 1 special ref from the list below. If ref is not
+       * matched to any special ref predicate, then it is checked against the standard rules - check
+       * the code of the {@link #validateRefUpdateContext} for details.
+       */
+      public static final RefUpdateContextValidator INSTANCE =
+          new RefUpdateContextValidator()
+              .addSpecialRef(RefNames::isSequenceRef, REPO_SEQ)
+              .addSpecialRef(RefNames.HEAD::equals, HEAD_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsChanges, CHANGE_MODIFICATION, MERGE_CHANGE)
+              .addSpecialRef(RefNames::isAutoMergeRef, CHANGE_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsEdit, CHANGE_MODIFICATION, MERGE_CHANGE)
+              .addSpecialRef(RefNames::isTagRef, TAG_MODIFICATION)
+              .addSpecialRef(RefNames::isRejectCommitsRef, BAN_COMMIT)
+              .addSpecialRef(
+                  name -> RefNames.isRefsUsers(name) && !RefNames.isRefsEdit(name),
+                  VERSIONED_META_DATA_CHANGE,
+                  ACCOUNTS_UPDATE,
+                  MERGE_CHANGE)
+              .addSpecialRef(
+                  RefNames::isConfigRef,
+                  VERSIONED_META_DATA_CHANGE,
+                  BRANCH_MODIFICATION,
+                  MERGE_CHANGE)
+              .addSpecialRef(RefNames::isExternalIdRef, VERSIONED_META_DATA_CHANGE, ACCOUNTS_UPDATE)
+              .addSpecialRef(PublicKeyStore.REFS_GPG_KEYS::equals, GPG_KEYS_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsDraftsComments, CHANGE_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsStarredChanges, CHANGE_MODIFICATION)
+              // A user can create a change for updating a group and then merge it.
+              // The GroupsIT#pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit test verifies
+              // this scenario.
+              .addSpecialRef(RefNames::isGroupRef, GROUPS_UPDATE, MERGE_CHANGE);
+
+      private List<Entry<Predicate<String>, ImmutableList<RefUpdateType>>> specialRefs =
+          new ArrayList<>();
+
+      private RefUpdateContextValidator() {}
+
+      public void validateRefUpdateContext(ReceiveCommand cmd) {
+        if (TestActionRefUpdateContext.isOpen()
+            || RefUpdateContext.hasOpen(OFFLINE_OPERATION)
+            || RefUpdateContext.hasOpen(INIT_REPO)
+            || RefUpdateContext.hasOpen(DIRECT_PUSH)) {
+          // The action can touch any refs in these contexts.
+          return;
+        }
+
+        String refName = cmd.getRefName();
+
+        Optional<ImmutableList<RefUpdateType>> allowedRefUpdateTypes =
+            RefUpdateContextValidator.INSTANCE.getAllowedRefUpdateTypes(refName);
+
+        if (allowedRefUpdateTypes.isPresent()) {
+          checkState(
+              allowedRefUpdateTypes.get().stream().anyMatch(RefUpdateContext::hasOpen)
+                  || isTestRepoCall(),
+              "Special ref '%s' is updated outside of the expected operation. Wrap code in the correct RefUpdateContext or fix allowed update types",
+              refName);
+          return;
+        }
+        // It is not one of the special ref - update is possible only within specific contexts.
+        checkState(
+            RefUpdateContext.hasOpen(MERGE_CHANGE)
+                || RefUpdateContext.hasOpen(RefUpdateType.BRANCH_MODIFICATION)
+                || RefUpdateContext.hasOpen(RefUpdateType.UPDATE_SUPERPROJECT)
+                || isTestRepoCall(),
+            "Ordinary ref '%s' is updated outside of the expected operation. Wrap code in the correct RefUpdateContext or add the ref as a special ref.",
+            refName);
+      }
+
+      private RefUpdateContextValidator addSpecialRef(
+          Predicate<String> refNamePredicate, RefUpdateType... validRefUpdateTypes) {
+        specialRefs.add(
+            new SimpleImmutableEntry<Predicate<String>, ImmutableList<RefUpdateType>>(
+                refNamePredicate, ImmutableList.copyOf(validRefUpdateTypes)));
+        return this;
+      }
+
+      private Optional<ImmutableList<RefUpdateType>> getAllowedRefUpdateTypes(String refName) {
+        List<ImmutableList<RefUpdateType>> allowedTypes =
+            specialRefs.stream()
+                .filter(entry -> entry.getKey().test(refName))
+                .map(Entry::getValue)
+                .collect(toList());
+        checkState(
+            allowedTypes.size() <= 1,
+            "refName matches more than 1 predicate. Please fix the specialRefs list, so each reference has no more than one match.");
+        if (allowedTypes.size() == 0) {
+          return Optional.empty();
+        }
+        return Optional.of(allowedTypes.get(0));
+      }
+
+      /**
+       * Returns true if a ref is updated using one of the method in {@link
+       * org.eclipse.jgit.junit.TestRepository}.
+       *
+       * <p>The {@link org.eclipse.jgit.junit.TestRepository} used only in tests and allows to
+       * change refs directly. Wrapping each usage in a test context requires a lot of modification,
+       * so instead we allow any ref updates, which are made using through this class.
+       */
+      private boolean isTestRepoCall() {
+        return Arrays.stream(Thread.currentThread().getStackTrace())
+            .anyMatch(elem -> elem.getClassName().equals("org.eclipse.jgit.junit.TestRepository"));
+      }
+    }
+
+    // The following line will be uncommented in the upcoming changes, after adding
+    // RefUpdateContext to the code.
+    static final boolean VALIDATE_REF_UPDATE_CONTEXT = false;
+
+    @Override
+    protected MemRefDatabase createRefDatabase() {
+      return new MemRefDatabase() {
+        @Override
+        public BatchRefUpdate newBatchUpdate() {
+          DfsObjDatabase odb = getRepository().getObjectDatabase();
+          return new DfsReftableBatchRefUpdate(this, odb) {
+            @Override
+            public void execute(RevWalk rw, ProgressMonitor pm, List<String> options) {
+              if (VALIDATE_REF_UPDATE_CONTEXT) {
+                getCommands().stream()
+                    .forEach(RefUpdateContextValidator.INSTANCE::validateRefUpdateContext);
+              }
+              super.execute(rw, pm, options);
+            }
+          };
+        }
+      };
+    }
+
     @Override
     public Description getDescription() {
       return (Description) super.getDescription();
diff --git a/java/com/google/gerrit/testing/TestActionRefUpdateContext.java b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
new file mode 100644
index 0000000..23ec9aa
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+
+/**
+ * Marks ref updates as a test actions.
+ *
+ * <p>This class should be used in tests only to wrap a portion of test code which directly modifies
+ * references. Usage:
+ *
+ * <pre>{@code
+ * import static ...TestActionRefUpdateContext.openTestRefUpdateContext();
+ *
+ * try(RefUpdateContext ctx=openTestRefUpdateContext()) {
+ *   // Some test code, which modifies a reference.
+ * }
+ * }</pre>
+ *
+ * or
+ *
+ * <pre>{@code
+ * import static ...TestActionRefUpdateContext.testRefAction;
+ *
+ * testRefAction(() -> {doSomethingWithRef()});
+ * T result = testRefAction(() -> { return doSomethingWithRef()});
+ * }</pre>
+ */
+public final class TestActionRefUpdateContext extends RefUpdateContext {
+  public static boolean isOpen() {
+    return getCurrent().stream().anyMatch(ctx -> ctx instanceof TestActionRefUpdateContext);
+  }
+
+  public static TestActionRefUpdateContext openTestRefUpdateContext() {
+    return open(new TestActionRefUpdateContext());
+  }
+
+  @CanIgnoreReturnValue
+  public static <V, E extends Exception> V testRefAction(CallableWithException<V, E> c) throws E {
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      return c.call();
+    }
+  }
+
+  public static <E extends Exception> void testRefAction(RunnableWithException<E> c) throws E {
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      c.run();
+    }
+  }
+
+  public interface CallableWithException<V, E extends Exception> {
+    V call() throws E;
+  }
+
+  @FunctionalInterface
+  public interface RunnableWithException<E extends Exception> {
+    void run() throws E;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/context/BUILD b/javatests/com/google/gerrit/server/update/context/BUILD
new file mode 100644
index 0000000..e580595
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/context/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "update_context_tests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
new file mode 100644
index 0000000..178d67d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update.context;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.After;
+import org.junit.Test;
+
+public class RefUpdateContextTest {
+  @After
+  public void tearDown() {
+    // Each test should close all opened context to avoid interference with other tests.
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+  }
+
+  @Test
+  public void contextNotOpen() {
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+  }
+
+  @Test
+  public void singleContext_openedAndClosedCorrectly() {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+      assertThat(openedContexts).hasSize(1);
+      assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+      assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+      assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    }
+
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isFalse();
+  }
+
+  @Test
+  public void nestedContext_openedAndClosedCorrectly() {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) {
+        ImmutableList<RefUpdateContext> nestedOpenedContexts = RefUpdateContext.getOpenedContexts();
+        assertThat(nestedOpenedContexts).hasSize(2);
+        assertThat(nestedOpenedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+        assertThat(nestedOpenedContexts.get(1).getUpdateType()).isEqualTo(INIT_REPO);
+        assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+        assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isTrue();
+        assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+      }
+      ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+      assertThat(openedContexts).hasSize(1);
+      assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+      assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+      assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+      assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+    }
+
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isFalse();
+    assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+  }
+
+  @Test
+  public void incorrectCloseOrder_exceptionThrown() {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) {
+        assertThrows(Exception.class, () -> ctx.close());
+        ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+        assertThat(openedContexts).hasSize(2);
+        assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+        assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isTrue();
+      }
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 3af8207..1d2a272 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -280,6 +280,7 @@
 export class PageContext {
   /**
    * Includes everything: base, path, query and hash.
+   * NOT decoded.
    */
   canonicalPath = '';
 
@@ -287,18 +288,21 @@
    * Does not include base path.
    * Does not include hash.
    * Includes query string.
+   * NOT decoded.
    */
   path = '';
 
-  /** Does not include hash. */
+  /** Decoded. Does not include hash. */
   querystring = '';
 
+  /** Decoded. */
   hash = '';
 
   /**
    * Regular expression matches of capturing groups. The first entry params[0]
    * corresponds to the first capturing group. The entire matched string is not
    * returned in this array.
+   * Each param is double decoded.
    */
   params: string[] = [];
 
@@ -346,17 +350,24 @@
   replaceState() {
     window.history.replaceState(this.state, this.title, this.canonicalPath);
   }
+
+  match(re: RegExp) {
+    const qsIndex = this.path.indexOf('?');
+    const pathname = qsIndex !== -1 ? this.path.slice(0, qsIndex) : this.path;
+    const matches = re.exec(decodeURIComponent(pathname));
+    if (matches) {
+      this.params = matches
+        .slice(1)
+        .map(match => decodeURIComponentString(match));
+    }
+    return !!matches;
+  }
 }
 
 function createRoute(re: RegExp, fn: Function) {
   return (ctx: PageContext, next: Function) => {
-    const qsIndex = ctx.path.indexOf('?');
-    const pathname = qsIndex !== -1 ? ctx.path.slice(0, qsIndex) : ctx.path;
-    const matches = re.exec(decodeURIComponent(pathname));
+    const matches = ctx.match(re);
     if (matches) {
-      ctx.params = matches
-        .slice(1)
-        .map(match => decodeURIComponentString(match));
       fn(ctx, next);
     } else {
       next();
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 997d9d5..da4a576 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -14,7 +14,6 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
-  DashboardId,
   GroupId,
   NumericChangeId,
   RevisionPatchSetNum,
@@ -73,6 +72,7 @@
 import {
   DashboardViewModel,
   DashboardViewState,
+  PROJECT_DASHBOARD_ROUTE,
 } from '../../../models/views/dashboard';
 import {
   SettingsViewModel,
@@ -107,7 +107,6 @@
 
   DASHBOARD: /^\/dashboard\/(.+)$/,
   CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
-  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
   LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
 
   AGREEMENTS: /^\/settings\/agreements\/?/,
@@ -635,10 +634,10 @@
       ctx => this.handleCustomDashboardRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.PROJECT_DASHBOARD,
-      'handleProjectDashboardRoute',
-      ctx => this.handleProjectDashboardRoute(ctx)
+    this.mapRouteState(
+      PROJECT_DASHBOARD_ROUTE,
+      this.dashboardViewModel,
+      'handleProjectDashboardRoute'
     );
 
     this.mapRoute(
@@ -1008,19 +1007,6 @@
     return Promise.resolve();
   }
 
-  handleProjectDashboardRoute(ctx: PageContext) {
-    const project = ctx.params[0] as RepoName;
-    const state: DashboardViewState = {
-      view: GerritView.DASHBOARD,
-      project,
-      dashboard: decodeURIComponent(ctx.params[1]) as DashboardId,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.dashboardViewModel.setState(state);
-    this.reporting.setRepoName(project);
-  }
-
   handleLegacyProjectDashboardRoute(ctx: PageContext) {
     this.redirect('/p/' + ctx.params[0] + '/+/dashboard/' + ctx.params[1]);
   }
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index da9c0c9..d66fec0 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -14,11 +14,27 @@
 import {GrAnnotation} from '../embed/diff/gr-diff-highlight/gr-annotation';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
 import {AppContext, injectAppContext} from '../services/app-context';
-import {Finalizable} from '../services/registry';
 import {PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  initVisibilityReporter,
+  initPerformanceReporter,
+  initErrorReporter,
+  initWebVitals,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {Finalizable} from '../services/registry';
 
-export function initGlobalVariables(appContext: AppContext & Finalizable) {
+export function initGlobalVariables(
+  appContext: AppContext & Finalizable,
+  initializeReporting: boolean
+) {
   injectAppContext(appContext);
+  if (initializeReporting) {
+    const reportingService = appContext.reportingService;
+    initVisibilityReporter(reportingService);
+    initPerformanceReporter(reportingService);
+    initWebVitals(reportingService);
+    initErrorReporter(reportingService);
+  }
   window.GrAnnotation = GrAnnotation;
   window.GrPluginActionContext = GrPluginActionContext;
 }
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 645f94a..ded0626 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -37,12 +37,6 @@
   createAppDependencies,
   Creator,
 } from '../services/app-context-init';
-import {
-  initVisibilityReporter,
-  initPerformanceReporter,
-  initErrorReporter,
-  initWebVitals,
-} from '../services/gr-reporting/gr-reporting_impl';
 import {html, LitElement} from 'lit';
 import {customElement} from 'lit/decorators.js';
 import {
@@ -50,14 +44,9 @@
   serviceWorkerInstallerToken,
 } from '../services/service-worker-installer';
 import {pluginLoaderToken} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {getAppContext} from '../services/app-context';
 
-const appContext = createAppContext();
-initGlobalVariables(appContext);
-const reportingService = appContext.reportingService;
-initVisibilityReporter(reportingService);
-initPerformanceReporter(reportingService);
-initWebVitals(reportingService);
-initErrorReporter(reportingService);
+initGlobalVariables(createAppContext(), true);
 
 installPolymerResin(safeTypesBridge);
 
@@ -97,7 +86,7 @@
     };
 
     for (const [token, creator] of createAppDependencies(
-      appContext,
+      getAppContext(),
       resolver
     )) {
       injectDependency(token, creator);
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index cf279ba..fcebeea 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -76,12 +76,12 @@
         element,
         /* HTML */ `
           <pre class="plaintext">
-            http://google.com/<a
+            <a
               href="http://google.com/LinkRewriteMe"
               rel="noopener"
               target="_blank"
             >
-              LinkRewriteMe
+            http://google.com/LinkRewriteMe
             </a>
           </pre>
         `
@@ -159,8 +159,15 @@
         element,
         /* HTML */ `
           <pre class="plaintext">
-          text with plain link: http://google.com
-        text with config link:
+          text with plain link:
+          <a
+            href="http://google.com"
+            rel="noopener"
+            target="_blank"
+          >
+            http://google.com
+          </a>
+          text with config link:
             <a
               href="http://google.com/LinkRewriteMe"
               rel="noopener"
@@ -269,7 +276,14 @@
         /* HTML */ `
           <pre class="plaintext">
           text
-        text with plain link: http://google.com
+        text with plain link:
+        <a
+          href="http://google.com"
+          rel="noopener"
+          target="_blank"
+        >
+          http://google.com
+        </a>
         text with config link:
           <a
             href="http://google.com/LinkRewriteMe"
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index b6089af..0881018 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -3,28 +3,34 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {assert} from '@open-wc/testing';
-import {PageContext} from '../../elements/core/gr-router/gr-page';
 import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
-import {AdminChildView, PLUGIN_LIST_ROUTE} from './admin';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+  PLUGIN_LIST_ROUTE,
+} from './admin';
 
 suite('admin view model', () => {
   suite('routes', () => {
     test('PLUGIN_LIST', () => {
-      const {urlPattern: pattern, createState} = PLUGIN_LIST_ROUTE;
+      assertRouteFalse(PLUGIN_LIST_ROUTE, 'admin/plugins');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins?');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '/admin/plugins//');
 
-      assert.isTrue(pattern.test('/admin/plugins'));
-      assert.isTrue(pattern.test('/admin/plugins/'));
-      assert.isFalse(pattern.test('admin/plugins'));
-      assert.isFalse(pattern.test('//admin/plugins'));
-      assert.isFalse(pattern.test('//admin/plugins?'));
-      assert.isFalse(pattern.test('/admin/plugins//'));
-
-      assert.deepEqual(createState(new PageContext('')), {
+      const state: AdminViewState = {
         view: GerritView.ADMIN,
         adminView: AdminChildView.PLUGINS,
-      });
+      };
+      assertRouteState<AdminViewState>(
+        PLUGIN_LIST_ROUTE,
+        '/admin/plugins',
+        state,
+        createAdminUrl
+      );
     });
   });
 });
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index 74523db..d2e7995 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -10,7 +10,21 @@
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+
+export const PROJECT_DASHBOARD_ROUTE: Route<DashboardViewState> = {
+  urlPattern: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+  createState: ctx => {
+    const project = (ctx.params[0] ?? '') as RepoName;
+    const dashboard = (ctx.params[1] ?? '') as DashboardId;
+    const state: DashboardViewState = {
+      view: GerritView.DASHBOARD,
+      project,
+      dashboard,
+    };
+    return state;
+  },
+};
 
 export interface DashboardViewState extends ViewState {
   view: GerritView.DASHBOARD;
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index a7620dd..9509977 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -5,11 +5,36 @@
  */
 import {assert} from '@open-wc/testing';
 import {RepoName} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
 import {DashboardId} from '../../types/common';
-import {createDashboardUrl} from './dashboard';
+import {
+  createDashboardUrl,
+  DashboardViewState,
+  PROJECT_DASHBOARD_ROUTE,
+} from './dashboard';
 
 suite('dashboard view state tests', () => {
+  suite('routes', () => {
+    test('PROJECT_DASHBOARD_ROUTE', () => {
+      assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p//+/dashboard/qwer');
+      assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p/asdf/+/dashboard/');
+
+      const state: DashboardViewState = {
+        view: GerritView.DASHBOARD,
+        project: 'asdf' as RepoName,
+        dashboard: 'qwer' as DashboardId,
+      };
+      assertRouteState(
+        PROJECT_DASHBOARD_ROUTE,
+        '/p/asdf/+/dashboard/qwer',
+        state,
+        createDashboardUrl
+      );
+    });
+  });
+
   suite('createDashboardUrl()', () => {
     test('self dashboard', () => {
       assert.equal(createDashboardUrl({}), '/dashboard/self');
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index aed58d8..365bb16 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -6,7 +6,7 @@
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 import '../scripts/bundled-polymer';
-import {AppContext} from '../services/app-context';
+import {getAppContext} from '../services/app-context';
 import {Finalizable} from '../services/registry';
 import {
   createTestAppContext,
@@ -60,7 +60,6 @@
 });
 
 let testSetupTimestampMs = 0;
-let appContext: AppContext & Finalizable;
 
 const injectedDependencies: Map<
   DependencyToken<unknown>,
@@ -101,11 +100,10 @@
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
-  appContext = createTestAppContext();
-  initGlobalVariables(appContext);
+  initGlobalVariables(createTestAppContext(), false);
 
-  finalizers.push(appContext);
-  const dependencies = createTestDependencies(appContext, testResolver);
+  finalizers.push(getAppContext());
+  const dependencies = createTestDependencies(getAppContext(), testResolver);
   for (const [token, provider] of dependencies) {
     injectDependency(token, provider);
   }
@@ -124,7 +122,7 @@
   // `awaitPluginsLoaded` will rely on that to kick off,
   // in testing, we want to kick start this earlier.
   testResolver(pluginLoaderToken).loadPlugins([]);
-  testOnlyResetGrRestApiSharedObjects(appContext.authService);
+  testOnlyResetGrRestApiSharedObjects(getAppContext().authService);
 });
 
 export function removeRequestDependencyListener() {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index c400d9c..19c3a7b 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -14,6 +14,8 @@
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
 import {assert} from '@open-wc/testing';
+import {Route, ViewState} from '../models/views/base';
+import {PageContext} from '../elements/core/gr-router/gr-page';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise<T> extends Promise<T> {
@@ -328,3 +330,26 @@
   };
   return new Proxy(obj, handler) as unknown as T;
 }
+
+export function assertRouteState<T extends ViewState>(
+  route: Route<T>,
+  path: string,
+  state: T,
+  createUrl: (state: T) => string
+) {
+  const {urlPattern, createState} = route;
+  const ctx = new PageContext(path);
+  const matches = ctx.match(urlPattern);
+  assert.isTrue(matches);
+  assert.deepEqual(createState(ctx), state);
+  assert.equal(path, createUrl(state));
+}
+
+export function assertRouteFalse<T extends ViewState>(
+  route: Route<T>,
+  path: string
+) {
+  const ctx = new PageContext(path);
+  const matches = ctx.match(route.urlPattern);
+  assert.isFalse(matches);
+}
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index 48e9c07..ccafa5a 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -33,6 +33,11 @@
     commentLinkInfo =>
       commentLinkInfo.enabled !== false && commentLinkInfo.link !== undefined
   );
+  // Always linkify URLs starting with https?://
+  enabledRewrites.push({
+    match: '(https?://\\S+[\\w/])',
+    link: '$1',
+  });
   return enabledRewrites.flatMap(rewrite => {
     const regexp = new RegExp(rewrite.match, 'g');
     const partialResults: RewriteResult[] = [];
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index e4e719b..52b8288 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -12,6 +12,17 @@
   }
 
   suite('link rewrites', () => {
+    test('default linking', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('http://www.google.com', {}),
+        link('http://www.google.com', 'http://www.google.com')
+      );
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('https://www.google.com', {}),
+        link('https://www.google.com', 'https://www.google.com')
+      );
+    });
+
     test('without text', () => {
       assert.equal(
         linkifyUrlsAndApplyRewrite('foo', {