Merge "Introduce RefUpdateContext in gerrit."
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();
+      }
+    }
+  }
+}