Merge "Merge branch 'stable-3.2'"
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 3b3a5fc..3364fc1 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -339,7 +339,7 @@
    * @return comments as a list; comments have the {@code revision} field set to indicate their
    *     patch set.
    * @throws RestApiException
-   * @deprecate Callers should use {@link #commentsRequest()} instead
+   * @deprecated Callers should use {@link #commentsRequest()} instead
    */
   @Deprecated
   default List<CommentInfo> commentsAsList() throws RestApiException {
@@ -351,7 +351,6 @@
    *
    * @return A {@link CommentsRequest} entity that can be used to retrieve the comments using the
    *     {@link CommentsRequest#get()} or {@link CommentsRequest#getAsList()}.
-   * @throws RestApiException
    */
   CommentsRequest commentsRequest() throws RestApiException;
 
@@ -429,7 +428,6 @@
      *
      * @return comments as a list; comments have the {@code revision} field set to indicate their
      *     patch set.
-     * @throws RestApiException
      */
     public abstract List<CommentInfo> getAsList() throws RestApiException;
 
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
index 813dad7..7f84693 100644
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -55,7 +55,7 @@
   }
 
   @Inject
-  public CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
+  CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
     this.repoManager = repoManager;
     this.project = project;
     this.candidates = new HashMap<>();
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 0023395..22d02d2 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -187,9 +187,11 @@
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
+import com.google.gerrit.server.submit.ConfiguredSubscriptionGraphFactory;
 import com.google.gerrit.server.submit.GitModules;
 import com.google.gerrit.server.submit.MergeSuperSetComputation;
 import com.google.gerrit.server.submit.SubmitStrategy;
+import com.google.gerrit.server.submit.SubscriptionGraph;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
@@ -453,6 +455,7 @@
     factory(VersionedAuthorizedKeys.Factory.class);
 
     bind(AccountManager.class);
+    bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
 
     bind(new TypeLiteral<List<CommentLinkInfo>>() {}).toProvider(CommentLinkProvider.class);
     DynamicSet.bind(binder(), GerritConfigListener.class).to(CommentLinkProvider.class);
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index fec7bdc..e3b433c 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -42,7 +41,6 @@
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
-  private final CommentContextLoader.Factory commentContextFactory;
 
   private boolean includeContext;
 
@@ -62,13 +60,11 @@
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
-      ChangeMessagesUtil changeMessagesUtil,
-      CommentContextLoader.Factory commentContextFactory) {
+      ChangeMessagesUtil changeMessagesUtil) {
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.changeMessagesUtil = changeMessagesUtil;
-    this.commentContextFactory = commentContextFactory;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index b66006a..a09ba63 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -193,7 +193,7 @@
       // was configured.
       MergeTip mergeTip = args.mergeTip;
       if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.submoduleOp.hasSubscription(args.destBranch)) {
+          && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
         PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
diff --git a/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
new file mode 100644
index 0000000..00e2443
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Wrap a {@link SubscriptionGraph.Factory} to honor the gerrit configuration.
+ *
+ * <p>If superproject subscriptions are disabled in the conf, return an empty graph.
+ */
+public class ConfiguredSubscriptionGraphFactory implements SubscriptionGraph.Factory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SubscriptionGraph.Factory subscriptionGraphFactory;
+  private final Config cfg;
+
+  @Inject
+  ConfiguredSubscriptionGraphFactory(
+      @VanillaSubscriptionGraph SubscriptionGraph.Factory subscriptionGraphFactory,
+      @GerritServerConfig Config cfg) {
+    this.subscriptionGraphFactory = subscriptionGraphFactory;
+    this.cfg = cfg;
+  }
+
+  @Override
+  public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+      throws SubmoduleConflictException {
+    if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
+      return subscriptionGraphFactory.compute(updatedBranches, orm);
+    } else {
+      logger.atFine().log("Updating superprojects disabled");
+      return SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/GitlinkOp.java b/java/com/google/gerrit/server/submit/GitlinkOp.java
new file mode 100644
index 0000000..70a52b6
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/GitlinkOp.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RepoOnlyOp;
+import java.util.Collection;
+import java.util.Optional;
+
+/** Only used for branches without code review changes */
+public class GitlinkOp implements RepoOnlyOp {
+
+  static class Factory {
+    private SubmoduleCommits submoduleCommits;
+    private SubscriptionGraph subscriptionGraph;
+
+    Factory(SubmoduleCommits submoduleCommits, SubscriptionGraph subscriptionGraph) {
+      this.submoduleCommits = submoduleCommits;
+      this.subscriptionGraph = subscriptionGraph;
+    }
+
+    GitlinkOp create(BranchNameKey branch) {
+      return new GitlinkOp(branch, submoduleCommits, subscriptionGraph.getSubscriptions(branch));
+    }
+  }
+
+  private final BranchNameKey branch;
+  private final SubmoduleCommits commitHelper;
+  private final Collection<SubmoduleSubscription> branchTargets;
+
+  GitlinkOp(
+      BranchNameKey branch,
+      SubmoduleCommits commitHelper,
+      Collection<SubmoduleSubscription> branchTargets) {
+    this.branch = branch;
+    this.commitHelper = commitHelper;
+    this.branchTargets = branchTargets;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws Exception {
+    Optional<CodeReviewCommit> commit = commitHelper.composeGitlinksCommit(branch, branchTargets);
+    if (commit.isPresent()) {
+      CodeReviewCommit c = commit.get();
+      ctx.addRefUpdate(c.getParent(0), c, branch.branch());
+      commitHelper.addBranchTip(branch, c);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeIfNecessary.java b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
index 82499b3..30f1661 100644
--- a/java/com/google/gerrit/server/submit/MergeIfNecessary.java
+++ b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
@@ -30,7 +30,7 @@
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
 
     if (args.mergeTip.getInitialTip() == null
-        || !args.submoduleOp.hasSubscription(args.destBranch)) {
+        || !args.subscriptionGraph.hasSubscription(args.destBranch)) {
       CodeReviewCommit firstFastForward =
           args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
       if (firstFastForward != null && !firstFastForward.equals(args.mergeTip.getInitialTip())) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 4dc2c1c..8d3324f 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -226,7 +226,8 @@
   private final MergeValidators.Factory mergeValidatorsFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final SubmitStrategyFactory submitStrategyFactory;
-  private final SubmoduleOp.Factory subOpFactory;
+  private final SubscriptionGraph.Factory subscriptionGraphFactory;
+  private final SubmoduleCommits.Factory submoduleCommitsFactory;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
@@ -256,7 +257,8 @@
       MergeValidators.Factory mergeValidatorsFactory,
       Provider<InternalChangeQuery> queryProvider,
       SubmitStrategyFactory submitStrategyFactory,
-      SubmoduleOp.Factory subOpFactory,
+      SubmoduleCommits.Factory submoduleCommitsFactory,
+      SubscriptionGraph.Factory subscriptionGraphFactory,
       Provider<MergeOpRepoManager> ormProvider,
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
@@ -269,7 +271,8 @@
     this.mergeValidatorsFactory = mergeValidatorsFactory;
     this.queryProvider = queryProvider;
     this.submitStrategyFactory = submitStrategyFactory;
-    this.subOpFactory = subOpFactory;
+    this.submoduleCommitsFactory = submoduleCommitsFactory;
+    this.subscriptionGraphFactory = subscriptionGraphFactory;
     this.ormProvider = ormProvider;
     this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
@@ -605,9 +608,13 @@
     commitStatus.maybeFailVerbose();
 
     try {
-      SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
-      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
-      this.allProjects = submoduleOp.getProjectsInOrder();
+      SubscriptionGraph subscriptionGraph = subscriptionGraphFactory.compute(branches, orm);
+      SubmoduleCommits submoduleCommits = submoduleCommitsFactory.create(orm);
+      UpdateOrderCalculator updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
+      List<SubmitStrategy> strategies =
+          getSubmitStrategies(
+              toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
+      this.allProjects = updateOrderCalculator.getProjectsInOrder();
       try {
         BatchUpdate.execute(
             orm.batchUpdates(allProjects),
@@ -658,12 +665,19 @@
   }
 
   private List<SubmitStrategy> getSubmitStrategies(
-      Map<BranchNameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+      Map<BranchNameKey, BranchBatch> toSubmit,
+      UpdateOrderCalculator updateOrderCalculator,
+      SubmoduleCommits submoduleCommits,
+      SubscriptionGraph subscriptionGraph,
+      boolean dryrun)
       throws IntegrationConflictException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
-    Set<BranchNameKey> allBranches = submoduleOp.getBranchesInOrder();
+    Set<BranchNameKey> allBranches = updateOrderCalculator.getBranchesInOrder();
     Set<CodeReviewCommit> allCommits =
         toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
+
+    GitlinkOp.Factory gitlinkOpFactory = new GitlinkOp.Factory(submoduleCommits, subscriptionGraph);
+
     for (BranchNameKey branch : allBranches) {
       OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
@@ -688,18 +702,19 @@
                 commitStatus,
                 submissionId,
                 submitInput,
-                submoduleOp,
+                submoduleCommits,
+                subscriptionGraph,
                 dryrun);
         strategies.add(strategy);
         strategy.addOps(or.getUpdate(), commitsToSubmit);
         if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY)
-            && submoduleOp.hasSubscription(branch)) {
-          submoduleOp.addOp(or.getUpdate(), branch);
+            && subscriptionGraph.hasSubscription(branch)) {
+          or.getUpdate().addRepoOnlyOp(gitlinkOpFactory.create(branch));
         }
       } else {
         // no open change for this branch
         // add submodule triggered op into BatchUpdate
-        submoduleOp.addOp(or.getUpdate(), branch);
+        or.getUpdate().addRepoOnlyOp(gitlinkOpFactory.create(branch));
       }
     }
     return strategies;
@@ -736,7 +751,7 @@
     @Nullable
     abstract SubmitType submitType();
 
-    abstract Set<CodeReviewCommit> commits();
+    abstract ImmutableSet<CodeReviewCommit> commits();
   }
 
   private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted) {
@@ -851,7 +866,7 @@
       toSubmit.add(commit);
     }
     logger.atFine().log("Submitting on this run: %s", toSubmit);
-    return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
+    return new AutoValue_MergeOp_BranchBatch(submitType, ImmutableSet.copyOf(toSubmit));
   }
 
   private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds) {
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index edc3725..db48cce 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -298,7 +298,7 @@
       // merge commits.
       MergeTip mergeTip = args.mergeTip;
       if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.submoduleOp.hasSubscription(args.destBranch)) {
+          && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
         PersonIdent caller =
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 4010ad7..21ff2fc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -97,7 +97,8 @@
           Set<CodeReviewCommit> incoming,
           SubmissionId submissionId,
           SubmitInput submitInput,
-          SubmoduleOp submoduleOp,
+          SubmoduleCommits submoduleCommits,
+          SubscriptionGraph subscriptionGraph,
           boolean dryrun);
     }
 
@@ -129,7 +130,8 @@
     final SubmissionId submissionId;
     final SubmitType submitType;
     final SubmitInput submitInput;
-    final SubmoduleOp submoduleOp;
+    final SubscriptionGraph subscriptionGraph;
+    final SubmoduleCommits submoduleCommits;
 
     final ProjectState project;
     final MergeSorter mergeSorter;
@@ -168,7 +170,8 @@
         @Assisted SubmissionId submissionId,
         @Assisted SubmitType submitType,
         @Assisted SubmitInput submitInput,
-        @Assisted SubmoduleOp submoduleOp,
+        @Assisted SubscriptionGraph subscriptionGraph,
+        @Assisted SubmoduleCommits submoduleCommits,
         @Assisted boolean dryrun) {
       this.accountCache = accountCache;
       this.approvalsUtil = approvalsUtil;
@@ -197,7 +200,8 @@
       this.submissionId = submissionId;
       this.submitType = submitType;
       this.submitInput = submitInput;
-      this.submoduleOp = submoduleOp;
+      this.submoduleCommits = submoduleCommits;
+      this.subscriptionGraph = subscriptionGraph;
       this.dryrun = dryrun;
 
       this.project =
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 1cc78ff..2e66ae2 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -55,7 +55,8 @@
       CommitStatus commitStatus,
       SubmissionId submissionId,
       SubmitInput submitInput,
-      SubmoduleOp submoduleOp,
+      SubmoduleCommits submoduleCommits,
+      SubscriptionGraph subscriptionGraph,
       boolean dryrun) {
     SubmitStrategy.Arguments args =
         argsFactory.create(
@@ -70,7 +71,8 @@
             incoming,
             submissionId,
             submitInput,
-            submoduleOp,
+            submoduleCommits,
+            subscriptionGraph,
             dryrun);
     switch (submitType) {
       case CHERRY_PICK:
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 3cc566b..3b77dd9 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -133,7 +133,7 @@
         new ReceiveCommand(
             firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().branch());
     ctx.addRefUpdate(command);
-    args.submoduleOp.addBranchTip(getDest(), tipAfter);
+    args.submoduleCommits.addBranchTip(getDest(), tipAfter);
   }
 
   private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit) {
@@ -535,13 +535,14 @@
    */
   protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
       throws IntegrationConflictException {
-    if (!args.submoduleOp.hasSubscription(args.destBranch)) {
+    if (!args.subscriptionGraph.hasSubscription(args.destBranch)) {
       return commit;
     }
 
     // Modify the commit with gitlink update
     try {
-      return args.submoduleOp.amendGitlinksCommit(args.destBranch, commit);
+      return args.submoduleCommits.amendGitlinksCommit(
+          args.destBranch, commit, args.subscriptionGraph.getSubscriptions(args.destBranch));
     } catch (IOException e) {
       throw new StorageException(
           String.format("cannot update gitlink for the commit at branch %s", args.destBranch), e);
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
new file mode 100644
index 0000000..1312a4b
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -0,0 +1,356 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Create commit or amend existing one updating gitlinks. */
+class SubmoduleCommits {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PersonIdent myIdent;
+  private final VerboseSuperprojectUpdate verboseSuperProject;
+  private final MergeOpRepoManager orm;
+  private final long maxCombinedCommitMessageSize;
+  private final long maxCommitMessages;
+  private final BranchTips branchTips = new BranchTips();
+
+  @Singleton
+  public static class Factory {
+    private final Provider<PersonIdent> serverIdent;
+    private final Config cfg;
+
+    @Inject
+    Factory(@GerritPersonIdent Provider<PersonIdent> serverIdent, @GerritServerConfig Config cfg) {
+      this.serverIdent = serverIdent;
+      this.cfg = cfg;
+    }
+
+    public SubmoduleCommits create(MergeOpRepoManager orm) {
+      return new SubmoduleCommits(orm, serverIdent.get(), cfg);
+    }
+  }
+
+  SubmoduleCommits(MergeOpRepoManager orm, PersonIdent myIdent, Config cfg) {
+    this.orm = orm;
+    this.myIdent = myIdent;
+    this.verboseSuperProject =
+        cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
+    this.maxCombinedCommitMessageSize =
+        cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
+    this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
+  }
+
+  /**
+   * Use the commit as tip of the branch
+   *
+   * <p>This keeps track of the tip of the branch as the submission progresses.
+   */
+  void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
+    branchTips.put(branch, tip);
+  }
+
+  /**
+   * Create a separate gitlink commit
+   *
+   * @param subscriber superproject (and branch)
+   * @param subscriptions subprojects the superproject is subscribed to
+   * @return a new commit on top of subscriber with gitlinks update to the tips of the subprojects;
+   *     empty if nothing has changed. Subproject tips are read from the cached branched tips
+   *     (defaulting to the mergeOpRepoManager).
+   */
+  Optional<CodeReviewCommit> composeGitlinksCommit(
+      BranchNameKey subscriber, Collection<SubmoduleSubscription> subscriptions)
+      throws IOException, SubmoduleConflictException {
+    OpenRepo or;
+    try {
+      or = orm.getRepo(subscriber.project());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new StorageException("Cannot access superproject", e);
+    }
+
+    CodeReviewCommit currentCommit =
+        branchTips
+            .getTip(subscriber, or)
+            .orElseThrow(
+                () ->
+                    new SubmoduleConflictException(
+                        "The branch was probably deleted from the subscriber repository"));
+
+    StringBuilder msgbuf = new StringBuilder();
+    PersonIdent author = null;
+    DirCache dc = readTree(or.getCodeReviewRevWalk(), currentCommit);
+    DirCacheEditor ed = dc.editor();
+    int count = 0;
+
+    for (SubmoduleSubscription s : sortByPath(subscriptions)) {
+      if (count > 0) {
+        msgbuf.append("\n\n");
+      }
+      RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
+      count++;
+      if (newCommit != null) {
+        PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
+        if (author == null) {
+          author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
+        } else if (!author.getName().equals(newCommitAuthor.getName())
+            || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
+          author = myIdent;
+        }
+      }
+    }
+    ed.finish();
+    ObjectId newTreeId = dc.writeTree(or.ins);
+
+    // Gitlinks are already in the branch, return null
+    if (newTreeId.equals(currentCommit.getTree())) {
+      return Optional.empty();
+    }
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(newTreeId);
+    commit.setParentId(currentCommit);
+    StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      commitMsg.append(msgbuf);
+    }
+    commit.setMessage(commitMsg.toString());
+    commit.setAuthor(author);
+    commit.setCommitter(myIdent);
+    ObjectId id = or.ins.insert(commit);
+    return Optional.of(or.getCodeReviewRevWalk().parseCommit(id));
+  }
+
+  /** Amend an existing commit with gitlink updates */
+  CodeReviewCommit amendGitlinksCommit(
+      BranchNameKey subscriber,
+      CodeReviewCommit currentCommit,
+      Collection<SubmoduleSubscription> subscriptions)
+      throws IOException, SubmoduleConflictException {
+    OpenRepo or;
+    try {
+      or = orm.getRepo(subscriber.project());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new StorageException("Cannot access superproject", e);
+    }
+
+    StringBuilder msgbuf = new StringBuilder();
+    DirCache dc = readTree(or.rw, currentCommit);
+    DirCacheEditor ed = dc.editor();
+    for (SubmoduleSubscription s : sortByPath(subscriptions)) {
+      updateSubmodule(dc, ed, msgbuf, s);
+    }
+    ed.finish();
+    ObjectId newTreeId = dc.writeTree(or.ins);
+
+    // Gitlinks are already updated, just return the commit
+    if (newTreeId.equals(currentCommit.getTree())) {
+      return currentCommit;
+    }
+    or.rw.parseBody(currentCommit);
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(newTreeId);
+    commit.setParentIds(currentCommit.getParents());
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      // TODO(czhen): handle cherrypick footer
+      commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
+    } else {
+      commit.setMessage(currentCommit.getFullMessage());
+    }
+    commit.setAuthor(currentCommit.getAuthorIdent());
+    commit.setCommitter(myIdent);
+    ObjectId id = or.ins.insert(commit);
+    CodeReviewCommit newCommit = or.getCodeReviewRevWalk().parseCommit(id);
+    newCommit.copyFrom(currentCommit);
+    return newCommit;
+  }
+
+  private RevCommit updateSubmodule(
+      DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
+      throws SubmoduleConflictException, IOException {
+    logger.atFine().log("Updating gitlink for %s", s);
+    OpenRepo subOr;
+    try {
+      subOr = orm.getRepo(s.getSubmodule().project());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new StorageException("Cannot access submodule", e);
+    }
+
+    DirCacheEntry dce = dc.getEntry(s.getPath());
+    RevCommit oldCommit = null;
+    if (dce != null) {
+      if (!dce.getFileMode().equals(FileMode.GITLINK)) {
+        String errMsg =
+            "Requested to update gitlink "
+                + s.getPath()
+                + " in "
+                + s.getSubmodule().project().get()
+                + " but entry "
+                + "doesn't have gitlink file mode.";
+        throw new SubmoduleConflictException(errMsg);
+      }
+      // Parse the current gitlink entry commit in the subproject repo. This is used to add a
+      // shortlog for this submodule to the commit message in the superproject.
+      //
+      // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
+      // check that the old gitlink is a commit that actually exists. If not, then there is an
+      // inconsistency between the superproject and subproject state, and we don't want to risk
+      // making things worse by updating the gitlink to something else.
+      try {
+        oldCommit = subOr.getCodeReviewRevWalk().parseCommit(dce.getObjectId());
+      } catch (IOException e) {
+        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
+        // proceed, it will just skip this gitlink update.
+        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
+        return null;
+      }
+    }
+
+    Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
+    if (!maybeNewCommit.isPresent()) {
+      // For whatever reason, this submodule was not updated as part of this submit batch, but the
+      // superproject is still subscribed to this branch. Re-read the ref to see if anything has
+      // changed since the last time the gitlink was updated, and roll that update into the same
+      // commit as all other submodule updates.
+      ed.add(new DeletePath(s.getPath()));
+      return null;
+    }
+
+    CodeReviewCommit newCommit = maybeNewCommit.get();
+    if (Objects.equals(newCommit, oldCommit)) {
+      // gitlink have already been updated for this submodule
+      return null;
+    }
+    ed.add(
+        new PathEdit(s.getPath()) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(newCommit.getId());
+          }
+        });
+
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
+    }
+    subOr.getCodeReviewRevWalk().parseBody(newCommit);
+    return newCommit;
+  }
+
+  private void createSubmoduleCommitMsg(
+      StringBuilder msgbuf,
+      SubmoduleSubscription s,
+      OpenRepo subOr,
+      RevCommit newCommit,
+      RevCommit oldCommit) {
+    msgbuf.append("* Update ");
+    msgbuf.append(s.getPath());
+    msgbuf.append(" from branch '");
+    msgbuf.append(s.getSubmodule().shortName());
+    msgbuf.append("'");
+    msgbuf.append("\n  to ");
+    msgbuf.append(newCommit.getName());
+
+    // newly created submodule gitlink, do not append whole history
+    if (oldCommit == null) {
+      return;
+    }
+
+    try {
+      subOr.rw.resetRetain(subOr.canMergeFlag);
+      subOr.rw.markStart(newCommit);
+      subOr.rw.markUninteresting(oldCommit);
+      int numMessages = 0;
+      for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
+        RevCommit c = iter.next();
+        subOr.rw.parseBody(c);
+
+        String message =
+            verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
+                ? c.getShortMessage()
+                : StringUtils.replace(c.getFullMessage(), "\n", "\n    ");
+
+        String bullet = "\n  - ";
+        String ellipsis = "\n\n[...]";
+        int newSize = msgbuf.length() + bullet.length() + message.length();
+        if (++numMessages > maxCommitMessages
+            || newSize > maxCombinedCommitMessageSize
+            || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
+          msgbuf.append(ellipsis);
+          break;
+        }
+        msgbuf.append(bullet);
+        msgbuf.append(message);
+      }
+    } catch (IOException e) {
+      throw new StorageException(
+          "Could not perform a revwalk to create superproject commit message", e);
+    }
+  }
+
+  private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
+    final DirCache dc = DirCache.newInCore();
+    final DirCacheBuilder b = dc.builder();
+    b.addTree(
+        new byte[0], // no prefix path
+        DirCacheEntry.STAGE_0, // standard stage
+        rw.getObjectReader(),
+        rw.parseTree(base));
+    b.finish();
+    return dc;
+  }
+
+  private static List<SubmoduleSubscription> sortByPath(
+      Collection<SubmoduleSubscription> subscriptions) {
+    return subscriptions.stream()
+        .sorted(comparing(SubmoduleSubscription::getPath))
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index a1ed373..8bfcd51 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,130 +14,60 @@
 
 package com.google.gerrit.server.submit;
 
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
 import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
-import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateListener;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
 import java.util.Set;
-import org.apache.commons.lang.StringUtils;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 public class SubmoduleOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  /** Only used for branches without code review changes */
-  public class GitlinkOp implements RepoOnlyOp {
-    private final BranchNameKey branch;
-    private final BranchTips currentBranchTips;
-
-    GitlinkOp(BranchNameKey branch, BranchTips branchTips) {
-      this.branch = branch;
-      this.currentBranchTips = branchTips;
-    }
-
-    @Override
-    public void updateRepo(RepoContext ctx) throws Exception {
-      CodeReviewCommit c = composeGitlinksCommit(branch);
-      if (c != null) {
-        ctx.addRefUpdate(c.getParent(0), c, branch.branch());
-        currentBranchTips.put(branch, c);
-      }
-    }
-  }
 
   @Singleton
   public static class Factory {
     private final SubscriptionGraph.Factory subscriptionGraphFactory;
-    private final Provider<PersonIdent> serverIdent;
-    private final Config cfg;
+    private final SubmoduleCommits.Factory submoduleCommitsFactory;
 
     @Inject
     Factory(
         SubscriptionGraph.Factory subscriptionGraphFactory,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        @GerritServerConfig Config cfg) {
+        SubmoduleCommits.Factory submoduleCommitsFactory) {
       this.subscriptionGraphFactory = subscriptionGraphFactory;
-      this.serverIdent = serverIdent;
-      this.cfg = cfg;
+      this.submoduleCommitsFactory = submoduleCommitsFactory;
     }
 
     public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleConflictException {
-      SubscriptionGraph subscriptionGraph;
-      if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
-        subscriptionGraph = subscriptionGraphFactory.compute(updatedBranches, orm);
-      } else {
-        logger.atFine().log("Updating superprojects disabled");
-        subscriptionGraph =
-            SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
-      }
-      return new SubmoduleOp(serverIdent.get(), cfg, orm, subscriptionGraph);
+      return new SubmoduleOp(
+          orm,
+          subscriptionGraphFactory.compute(updatedBranches, orm),
+          submoduleCommitsFactory.create(orm));
     }
   }
 
-  private final PersonIdent myIdent;
-  private final VerboseSuperprojectUpdate verboseSuperProject;
-  private final long maxCombinedCommitMessageSize;
-  private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
   private final SubscriptionGraph subscriptionGraph;
-
-  private final BranchTips branchTips = new BranchTips();
+  private final SubmoduleCommits submoduleCommits;
+  private final UpdateOrderCalculator updateOrderCalculator;
 
   private SubmoduleOp(
-      PersonIdent myIdent,
-      Config cfg,
       MergeOpRepoManager orm,
-      SubscriptionGraph subscriptionGraph) {
-    this.myIdent = myIdent;
-    this.verboseSuperProject =
-        cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
-    this.maxCombinedCommitMessageSize =
-        cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
-    this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
+      SubscriptionGraph subscriptionGraph,
+      SubmoduleCommits submoduleCommits) {
     this.orm = orm;
     this.subscriptionGraph = subscriptionGraph;
+    this.submoduleCommits = submoduleCommits;
+    this.updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
   }
 
   @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
@@ -146,13 +76,15 @@
   }
 
   public void updateSuperProjects() throws RestApiException {
-    ImmutableSet<Project.NameKey> projects = getProjectsInOrder();
+    ImmutableSet<Project.NameKey> projects = updateOrderCalculator.getProjectsInOrder();
     if (projects == null) {
       return;
     }
 
     LinkedHashSet<Project.NameKey> superProjects = new LinkedHashSet<>();
     try {
+      GitlinkOp.Factory gitlinkOpFactory =
+          new GitlinkOp.Factory(submoduleCommits, subscriptionGraph);
       for (Project.NameKey project : projects) {
         // only need superprojects
         if (subscriptionGraph.isAffectedSuperProject(project)) {
@@ -160,7 +92,7 @@
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
           for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
-            addOp(or.getUpdate(), branch);
+            or.getUpdate().addRepoOnlyOp(gitlinkOpFactory.create(branch));
           }
         }
       }
@@ -169,306 +101,4 @@
       throw new StorageException("Cannot update gitlinks", e);
     }
   }
-
-  /** Create a separate gitlink commit */
-  private CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber)
-      throws IOException, SubmoduleConflictException {
-    OpenRepo or;
-    try {
-      or = orm.getRepo(subscriber.project());
-    } catch (NoSuchProjectException | IOException e) {
-      throw new StorageException("Cannot access superproject", e);
-    }
-
-    CodeReviewCommit currentCommit =
-        branchTips
-            .getTip(subscriber, or)
-            .orElseThrow(
-                () ->
-                    new SubmoduleConflictException(
-                        "The branch was probably deleted from the subscriber repository"));
-
-    StringBuilder msgbuf = new StringBuilder();
-    PersonIdent author = null;
-    DirCache dc = readTree(or.rw, currentCommit);
-    DirCacheEditor ed = dc.editor();
-    int count = 0;
-
-    List<SubmoduleSubscription> subscriptions =
-        subscriptionGraph.getSubscriptions(subscriber).stream()
-            .sorted(comparing(SubmoduleSubscription::getPath))
-            .collect(toList());
-    for (SubmoduleSubscription s : subscriptions) {
-      if (count > 0) {
-        msgbuf.append("\n\n");
-      }
-      RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
-      count++;
-      if (newCommit != null) {
-        PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
-        if (author == null) {
-          author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
-        } else if (!author.getName().equals(newCommitAuthor.getName())
-            || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
-          author = myIdent;
-        }
-      }
-    }
-    ed.finish();
-    ObjectId newTreeId = dc.writeTree(or.ins);
-
-    // Gitlinks are already in the branch, return null
-    if (newTreeId.equals(currentCommit.getTree())) {
-      return null;
-    }
-    CommitBuilder commit = new CommitBuilder();
-    commit.setTreeId(newTreeId);
-    commit.setParentId(currentCommit);
-    StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
-    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      commitMsg.append(msgbuf);
-    }
-    commit.setMessage(commitMsg.toString());
-    commit.setAuthor(author);
-    commit.setCommitter(myIdent);
-    ObjectId id = or.ins.insert(commit);
-    return or.rw.parseCommit(id);
-  }
-
-  /** Amend an existing commit with gitlink updates */
-  CodeReviewCommit amendGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
-      throws IOException, SubmoduleConflictException {
-    OpenRepo or;
-    try {
-      or = orm.getRepo(subscriber.project());
-    } catch (NoSuchProjectException | IOException e) {
-      throw new StorageException("Cannot access superproject", e);
-    }
-
-    StringBuilder msgbuf = new StringBuilder();
-    DirCache dc = readTree(or.rw, currentCommit);
-    DirCacheEditor ed = dc.editor();
-    for (SubmoduleSubscription s : subscriptionGraph.getSubscriptions(subscriber)) {
-      updateSubmodule(dc, ed, msgbuf, s);
-    }
-    ed.finish();
-    ObjectId newTreeId = dc.writeTree(or.ins);
-
-    // Gitlinks are already updated, just return the commit
-    if (newTreeId.equals(currentCommit.getTree())) {
-      return currentCommit;
-    }
-    or.rw.parseBody(currentCommit);
-    CommitBuilder commit = new CommitBuilder();
-    commit.setTreeId(newTreeId);
-    commit.setParentIds(currentCommit.getParents());
-    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      // TODO(czhen): handle cherrypick footer
-      commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
-    } else {
-      commit.setMessage(currentCommit.getFullMessage());
-    }
-    commit.setAuthor(currentCommit.getAuthorIdent());
-    commit.setCommitter(myIdent);
-    ObjectId id = or.ins.insert(commit);
-    CodeReviewCommit newCommit = or.rw.parseCommit(id);
-    newCommit.copyFrom(currentCommit);
-    return newCommit;
-  }
-
-  private RevCommit updateSubmodule(
-      DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
-      throws SubmoduleConflictException, IOException {
-    logger.atFine().log("Updating gitlink for %s", s);
-    OpenRepo subOr;
-    try {
-      subOr = orm.getRepo(s.getSubmodule().project());
-    } catch (NoSuchProjectException | IOException e) {
-      throw new StorageException("Cannot access submodule", e);
-    }
-
-    DirCacheEntry dce = dc.getEntry(s.getPath());
-    RevCommit oldCommit = null;
-    if (dce != null) {
-      if (!dce.getFileMode().equals(FileMode.GITLINK)) {
-        String errMsg =
-            "Requested to update gitlink "
-                + s.getPath()
-                + " in "
-                + s.getSubmodule().project().get()
-                + " but entry "
-                + "doesn't have gitlink file mode.";
-        throw new SubmoduleConflictException(errMsg);
-      }
-      // Parse the current gitlink entry commit in the subproject repo. This is used to add a
-      // shortlog for this submodule to the commit message in the superproject.
-      //
-      // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
-      // check that the old gitlink is a commit that actually exists. If not, then there is an
-      // inconsistency between the superproject and subproject state, and we don't want to risk
-      // making things worse by updating the gitlink to something else.
-      try {
-        oldCommit = subOr.rw.parseCommit(dce.getObjectId());
-      } catch (IOException e) {
-        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
-        // proceed, it will just skip this gitlink update.
-        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
-        return null;
-      }
-    }
-
-    Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
-    if (!maybeNewCommit.isPresent()) {
-      // This submodule branch is neither in the submit set nor in the repository itself
-      ed.add(new DeletePath(s.getPath()));
-      return null;
-    }
-
-    CodeReviewCommit newCommit = maybeNewCommit.get();
-
-    if (Objects.equals(newCommit, oldCommit)) {
-      // gitlink have already been updated for this submodule
-      return null;
-    }
-    ed.add(
-        new PathEdit(s.getPath()) {
-          @Override
-          public void apply(DirCacheEntry ent) {
-            ent.setFileMode(FileMode.GITLINK);
-            ent.setObjectId(newCommit.getId());
-          }
-        });
-
-    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
-    }
-    subOr.rw.parseBody(newCommit);
-    return newCommit;
-  }
-
-  private void createSubmoduleCommitMsg(
-      StringBuilder msgbuf,
-      SubmoduleSubscription s,
-      OpenRepo subOr,
-      RevCommit newCommit,
-      RevCommit oldCommit) {
-    msgbuf.append("* Update ");
-    msgbuf.append(s.getPath());
-    msgbuf.append(" from branch '");
-    msgbuf.append(s.getSubmodule().shortName());
-    msgbuf.append("'");
-    msgbuf.append("\n  to ");
-    msgbuf.append(newCommit.getName());
-
-    // newly created submodule gitlink, do not append whole history
-    if (oldCommit == null) {
-      return;
-    }
-
-    try {
-      subOr.rw.resetRetain(subOr.canMergeFlag);
-      subOr.rw.markStart(newCommit);
-      subOr.rw.markUninteresting(oldCommit);
-      int numMessages = 0;
-      for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
-        RevCommit c = iter.next();
-        subOr.rw.parseBody(c);
-
-        String message =
-            verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
-                ? c.getShortMessage()
-                : StringUtils.replace(c.getFullMessage(), "\n", "\n    ");
-
-        String bullet = "\n  - ";
-        String ellipsis = "\n\n[...]";
-        int newSize = msgbuf.length() + bullet.length() + message.length();
-        if (++numMessages > maxCommitMessages
-            || newSize > maxCombinedCommitMessageSize
-            || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
-          msgbuf.append(ellipsis);
-          break;
-        }
-        msgbuf.append(bullet);
-        msgbuf.append(message);
-      }
-    } catch (IOException e) {
-      throw new StorageException(
-          "Could not perform a revwalk to create superproject commit message", e);
-    }
-  }
-
-  private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
-    final DirCache dc = DirCache.newInCore();
-    final DirCacheBuilder b = dc.builder();
-    b.addTree(
-        new byte[0], // no prefix path
-        DirCacheEntry.STAGE_0, // standard stage
-        rw.getObjectReader(),
-        rw.parseTree(base));
-    b.finish();
-    return dc;
-  }
-
-  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
-    LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
-    for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
-      addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
-    }
-
-    for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
-      projects.add(branch.project());
-    }
-    return ImmutableSet.copyOf(projects);
-  }
-
-  private void addAllSubmoduleProjects(
-      Project.NameKey project,
-      LinkedHashSet<Project.NameKey> current,
-      LinkedHashSet<Project.NameKey> projects)
-      throws SubmoduleConflictException {
-    if (current.contains(project)) {
-      throw new SubmoduleConflictException(
-          "Project level circular subscriptions detected:  "
-              + CircularPathFinder.printCircularPath(current, project));
-    }
-
-    if (projects.contains(project)) {
-      return;
-    }
-
-    current.add(project);
-    Set<Project.NameKey> subprojects = new HashSet<>();
-    for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
-      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
-      for (SubmoduleSubscription s : subscriptions) {
-        subprojects.add(s.getSubmodule().project());
-      }
-    }
-
-    for (Project.NameKey p : subprojects) {
-      addAllSubmoduleProjects(p, current, projects);
-    }
-
-    current.remove(project);
-    projects.add(project);
-  }
-
-  ImmutableSet<BranchNameKey> getBranchesInOrder() {
-    LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
-    branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
-    branches.addAll(subscriptionGraph.getUpdatedBranches());
-    return ImmutableSet.copyOf(branches);
-  }
-
-  boolean hasSubscription(BranchNameKey branch) {
-    return subscriptionGraph.hasSubscription(branch);
-  }
-
-  void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
-    branchTips.put(branch, tip);
-  }
-
-  void addOp(BatchUpdate bu, BranchNameKey branch) {
-    bu.addRepoOnlyOp(new GitlinkOp(branch, branchTips));
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
index b08e46d1..b11b2be 100644
--- a/java/com/google/gerrit/server/submit/SubscriptionGraph.java
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -159,7 +159,7 @@
   public static class Module extends AbstractModule {
     @Override
     protected void configure() {
-      bind(Factory.class).to(DefaultFactory.class);
+      bind(Factory.class).annotatedWith(VanillaSubscriptionGraph.class).to(DefaultFactory.class);
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
new file mode 100644
index 0000000..517c708
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Sorts the projects or branches affected by the update.
+ *
+ * <p>The subscription graph contains all branches (and projects) affected by the update, but the
+ * updates must be executed in the right order, so no superproject reference is updated before its
+ * target.
+ */
+class UpdateOrderCalculator {
+
+  private final SubscriptionGraph subscriptionGraph;
+
+  UpdateOrderCalculator(SubscriptionGraph subscriptionGraph) {
+    this.subscriptionGraph = subscriptionGraph;
+  }
+
+  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
+    LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
+    for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
+      addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
+    }
+
+    for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
+      projects.add(branch.project());
+    }
+    return ImmutableSet.copyOf(projects);
+  }
+
+  private void addAllSubmoduleProjects(
+      Project.NameKey project,
+      LinkedHashSet<Project.NameKey> current,
+      LinkedHashSet<Project.NameKey> projects)
+      throws SubmoduleConflictException {
+    if (current.contains(project)) {
+      throw new SubmoduleConflictException(
+          "Project level circular subscriptions detected:  "
+              + CircularPathFinder.printCircularPath(current, project));
+    }
+
+    if (projects.contains(project)) {
+      return;
+    }
+
+    current.add(project);
+    Set<Project.NameKey> subprojects = new HashSet<>();
+    for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
+      for (SubmoduleSubscription s : subscriptions) {
+        subprojects.add(s.getSubmodule().project());
+      }
+    }
+
+    for (Project.NameKey p : subprojects) {
+      addAllSubmoduleProjects(p, current, projects);
+    }
+
+    current.remove(project);
+    projects.add(project);
+  }
+
+  ImmutableSet<BranchNameKey> getBranchesInOrder() {
+    LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
+    branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
+    branches.addAll(subscriptionGraph.getUpdatedBranches());
+    return ImmutableSet.copyOf(branches);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java b/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java
new file mode 100644
index 0000000..a88157e
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on a {@link SubscriptionGraph.Factory} without gerrit configuration.
+ *
+ * <p>See {@link ConfiguredSubscriptionGraphFactory}.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface VanillaSubscriptionGraph {}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 72453fd..903ce1a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1775,7 +1775,7 @@
   }
 
   private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null, 1, null);
+    addComment(r, message, false, false, null, null, null);
   }
 
   private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
@@ -1801,7 +1801,7 @@
       Boolean unresolved,
       String inReplyTo)
       throws Exception {
-    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, 1, null);
+    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
   }
 
   private void addComment(
@@ -1814,7 +1814,7 @@
       Comment.Range range)
       throws Exception {
     CommentInput c = new CommentInput();
-    c.line = 1;
+    c.line = line == null ? 1 : line;
     c.message = message;
     c.path = FILE_NAME;
     c.unresolved = unresolved;
diff --git a/javatests/com/google/gerrit/server/submit/BUILD b/javatests/com/google/gerrit/server/submit/BUILD
new file mode 100644
index 0000000..7425bc8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/BUILD
@@ -0,0 +1,21 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "submit_tests",
+    size = "small",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/mockito",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//lib/truth:truth-proto-extension",
+        "@jgit//org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
new file mode 100644
index 0000000..313e697
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class SubmoduleCommitsTest {
+
+  private static final String MASTER = "refs/heads/master";
+  private static final Project.NameKey superProject = Project.nameKey("superproject");
+  private static final Project.NameKey subProject = Project.nameKey("subproject");
+
+  private static final PersonIdent ident = new PersonIdent("submodule-test", "a@b.com");
+
+  private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private MergeOpRepoManager mergeOpRepoManager;
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+  @Mock private ProjectCache mockProjectCache;
+  @Mock private ProjectState mockProjectState;
+
+  @Test
+  public void createGitlinksCommit_subprojectMoved() throws Exception {
+    createRepo(subProject, MASTER);
+    createRepo(superProject, MASTER);
+
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    ObjectId subprojectCommit = getTip(subProject, MASTER);
+    RevCommit superprojectTip =
+        directUpdateSubmodule(superProject, MASTER, Project.nameKey("dir-x"), subprojectCommit);
+    assertThat(readGitLink(superProject, superprojectTip, "dir-x")).isEqualTo(subprojectCommit);
+
+    RevCommit newSubprojectCommit = addCommit(subProject, MASTER);
+
+    BranchNameKey superBranch = BranchNameKey.create(superProject, MASTER);
+    BranchNameKey subBranch = BranchNameKey.create(subProject, MASTER);
+    SubmoduleSubscription ss = new SubmoduleSubscription(superBranch, subBranch, "dir-x");
+    SubmoduleCommits helper = new SubmoduleCommits(mergeOpRepoManager, ident, new Config());
+    Optional<CodeReviewCommit> newGitLinksCommit =
+        helper.composeGitlinksCommit(
+            BranchNameKey.create(superProject, MASTER), ImmutableList.of(ss));
+
+    assertThat(newGitLinksCommit).isPresent();
+    assertThat(newGitLinksCommit.get().getParent(0)).isEqualTo(superprojectTip);
+    assertThat(readGitLink(superProject, newGitLinksCommit.get(), "dir-x"))
+        .isEqualTo(newSubprojectCommit);
+  }
+
+  @Test
+  public void amendGitlinksCommit_subprojectMoved() throws Exception {
+    createRepo(subProject, MASTER);
+    createRepo(superProject, MASTER);
+
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    ObjectId subprojectCommit = getTip(subProject, MASTER);
+    CodeReviewCommit superprojectTip =
+        directUpdateSubmodule(superProject, MASTER, Project.nameKey("dir-x"), subprojectCommit);
+    assertThat(readGitLink(superProject, superprojectTip, "dir-x")).isEqualTo(subprojectCommit);
+
+    RevCommit newSubprojectCommit = addCommit(subProject, MASTER);
+
+    BranchNameKey superBranch = BranchNameKey.create(superProject, MASTER);
+    BranchNameKey subBranch = BranchNameKey.create(subProject, MASTER);
+    SubmoduleSubscription ss = new SubmoduleSubscription(superBranch, subBranch, "dir-x");
+    SubmoduleCommits helper = new SubmoduleCommits(mergeOpRepoManager, ident, new Config());
+    CodeReviewCommit amendedCommit =
+        helper.amendGitlinksCommit(
+            BranchNameKey.create(superProject, MASTER), superprojectTip, ImmutableList.of(ss));
+
+    assertThat(amendedCommit.getParent(0)).isEqualTo(superprojectTip.getParent(0));
+    assertThat(readGitLink(superProject, amendedCommit, "dir-x")).isEqualTo(newSubprojectCommit);
+  }
+
+  /** Create repo with a commit on refName */
+  private void createRepo(Project.NameKey projectKey, String refName) throws Exception {
+    Repository repo = repoManager.createRepository(projectKey);
+    try (TestRepository<Repository> git = new TestRepository<>(repo)) {
+      RevCommit newCommit = git.commit().message("Initial commit for " + projectKey).create();
+      git.update(refName, newCommit);
+    }
+  }
+
+  private ObjectId getTip(Project.NameKey projectKey, String refName)
+      throws RepositoryNotFoundException, IOException {
+    return repoManager.openRepository(projectKey).exactRef(refName).getObjectId();
+  }
+
+  private RevCommit addCommit(Project.NameKey projectKey, String refName) throws Exception {
+    try (Repository serverRepo = repoManager.openRepository(projectKey);
+        RevWalk rw = new RevWalk(serverRepo);
+        TestRepository<Repository> git = new TestRepository<>(serverRepo, rw)) {
+      Ref ref = serverRepo.exactRef(refName);
+      assertWithMessage(refName).that(ref).isNotNull();
+
+      RevCommit originalTip = rw.parseCommit(ref.getObjectId());
+      RevCommit newTip =
+          git.commit().parent(originalTip).message("Added commit to " + projectKey).create();
+      git.update(refName, newTip);
+      return newTip;
+    }
+  }
+
+  private CodeReviewCommit directUpdateSubmodule(
+      Project.NameKey project, String refName, Project.NameKey path, AnyObjectId id)
+      throws Exception {
+    OpenRepo or = mergeOpRepoManager.getRepo(project);
+    Repository serverRepo = or.repo;
+    ObjectInserter ins = or.ins;
+    CodeReviewRevWalk rw = or.rw;
+    Ref ref = serverRepo.exactRef(refName);
+    assertWithMessage(refName).that(ref).isNotNull();
+    ObjectId oldCommitId = ref.getObjectId();
+
+    DirCache dc = DirCache.newInCore();
+    DirCacheBuilder b = dc.builder();
+    b.addTree(new byte[0], DirCacheEntry.STAGE_0, rw.getObjectReader(), rw.parseTree(oldCommitId));
+    b.finish();
+    DirCacheEditor e = dc.editor();
+    e.add(
+        new PathEdit(path.get()) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(id);
+          }
+        });
+    e.finish();
+
+    CommitBuilder cb = new CommitBuilder();
+    cb.addParentId(oldCommitId);
+    cb.setTreeId(dc.writeTree(ins));
+
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    cb.setMessage("Direct update submodule " + path);
+    ObjectId newCommitId = ins.insert(cb);
+    ins.flush();
+
+    RefUpdate ru = serverRepo.updateRef(refName);
+    ru.setExpectedOldObjectId(oldCommitId);
+    ru.setNewObjectId(newCommitId);
+    assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+    return rw.parseCommit(newCommitId);
+  }
+
+  private ObjectId readGitLink(Project.NameKey projectKey, RevCommit commit, String path)
+      throws IOException, NoSuchProjectException {
+    // SubmoduleCommitHelper used mergeOpRepoManager to create the commit
+    // Read the repo from mergeOpRepoManager to get also the RevWalk that created the commit
+    return readGitLinkInCommit(mergeOpRepoManager.getRepo(projectKey).rw, commit, path);
+  }
+
+  private ObjectId readGitLinkInCommit(RevWalk rw, RevCommit commit, String path)
+      throws IOException {
+    DirCache dc = DirCache.newInCore();
+    DirCacheBuilder b = dc.builder();
+    b.addTree(
+        new byte[0], // no prefix path
+        DirCacheEntry.STAGE_0, // standard stage
+        rw.getObjectReader(),
+        commit.getTree());
+    b.finish();
+    DirCacheEntry entry = dc.getEntry(path);
+    assertThat(entry.getFileMode()).isEqualTo(FileMode.GITLINK);
+    return entry.getObjectId();
+  }
+}