Merge "Return X-Gerrit-UpdatedRef in the response headers of WRITE requests"
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index eabcaa9..8709843 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -244,6 +244,38 @@
 Given the trace ID an administrator can find the corresponding logs and
 investigate issues more easily.
 
+[[updated-refs]]
+=== X-Gerrit-UpdatedRef
+For each write REST request, we return X-Gerrit-UpdatedRef headers as the refs
+that were updated in the current request (involved in a ref transaction in the
+current request).
+
+The format of those headers is `PROJECT_NAME~REF_NAME~OLD_SHA-1~NEW_SHA-1`. The
+project and ref names are URL-encoded, and must use %7E for '~'.
+
+A new SHA-1 of `0000000000000000000000000000000000000000` is treated as a
+deleted ref.
+If the new SHA-1 is not `0000000000000000000000000000000000000000`, the ref was
+either updated or created.
+If the old SHA-1 is `0000000000000000000000000000000000000000`, the ref was
+created.
+
+.Example Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940
+----
+
+.Example Response
+----
+HTTP/1.1 204 NO CONTENT
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+  X-Gerrit-UpdatedRef: myProject~refs%2Fchanges%2F01%2F1%2F1~deadbeefdeadbeefdeadbeefdeadbeefdeadbeef~0000000000000000000000000000000000000000
+  X-Gerrit-UpdatedRef: myProject~refs%2Fchanges%2F01%2F1%2Fmeta~deadbeefdeadbeefdeadbeefdeadbeefdeadbeef~0000000000000000000000000000000000000000
+
+  )]}'
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 7212e3e..0a54448 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -42,7 +42,7 @@
 import org.eclipse.jgit.http.server.GitSmartHttpTools;
 
 @RequestScoped
-public abstract class CacheBasedWebSession implements WebSession {
+public abstract class CacheBasedWebSession extends WebSession {
   @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
   protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
diff --git a/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
new file mode 100644
index 0000000..c37d30b
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 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.httpd;
+
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * Stores the updated refs whenever they are updated, so that we can export this information in the
+ * response headers.
+ */
+@Singleton
+public class GitReferenceUpdatedTracker implements GitReferenceUpdatedListener {
+
+  private final DynamicItem<WebSession> webSession;
+
+  @Inject
+  GitReferenceUpdatedTracker(DynamicItem<WebSession> webSession) {
+    this.webSession = webSession;
+  }
+
+  @Override
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
+    WebSession currentSession = webSession.get();
+    if (currentSession != null) {
+      currentSession.addRefUpdatedEvents(event);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/HttpdModule.java b/java/com/google/gerrit/httpd/HttpdModule.java
new file mode 100644
index 0000000..1f1ec2f
--- /dev/null
+++ b/java/com/google/gerrit/httpd/HttpdModule.java
@@ -0,0 +1,14 @@
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+
+public class HttpdModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(GitReferenceUpdatedListener.class)
+        .annotatedWith(Exports.named(GitReferenceUpdatedTracker.class.getSimpleName()))
+        .to(GitReferenceUpdatedTracker.class);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index daf30ff..df8402e 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -16,30 +16,61 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.inject.servlet.RequestScoped;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 
-public interface WebSession {
-  boolean isSignedIn();
+/**
+ * A thread safe class that contains details about a specific user web session.
+ *
+ * <p>WARNING: All implementors must have {@link RequestScoped} annotation to maintain thread
+ * safety.
+ */
+public abstract class WebSession {
+  public abstract boolean isSignedIn();
 
   @Nullable
-  String getXGerritAuth();
+  public abstract String getXGerritAuth();
 
-  boolean isValidXGerritAuth(String keyIn);
+  public abstract boolean isValidXGerritAuth(String keyIn);
 
-  CurrentUser getUser();
+  public abstract CurrentUser getUser();
 
-  void login(AuthResult res, boolean rememberMe);
+  public abstract void login(AuthResult res, boolean rememberMe);
 
   /** Set the user account for this current request only. */
-  void setUserAccountId(Account.Id id);
+  public abstract void setUserAccountId(Account.Id id);
 
-  boolean isAccessPathOk(AccessPath path);
+  public abstract boolean isAccessPathOk(AccessPath path);
 
-  void setAccessPathOk(AccessPath path, boolean ok);
+  public abstract void setAccessPathOk(AccessPath path, boolean ok);
 
-  void logout();
+  public abstract void logout();
 
-  String getSessionId();
+  public abstract String getSessionId();
+
+  /**
+   * Store and return the ref updates in this session. This class is {@link RequestScoped}, hence
+   * this is thread safe.
+   *
+   * <p>The same session could perform separate requests one after another, so resetting the ref
+   * updates is necessary between requests.
+   */
+  private List<GitReferenceUpdatedListener.Event> refUpdatedEvents = new CopyOnWriteArrayList<>();
+
+  public List<GitReferenceUpdatedListener.Event> getRefUpdatedEvents() {
+    return refUpdatedEvents;
+  }
+
+  public void addRefUpdatedEvents(GitReferenceUpdatedListener.Event event) {
+    refUpdatedEvents.add(event);
+  }
+
+  public void resetRefUpdatedEvents() {
+    refUpdatedEvents.clear();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 3a77a8a..079f306 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.HttpdModule;
 import com.google.gerrit.httpd.RequestCleanupFilter;
 import com.google.gerrit.httpd.RequestContextFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
@@ -393,6 +394,7 @@
     modules.add(RequestMetricsFilter.module());
     modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(HttpdModule.class));
     modules.add(RequestCleanupFilter.module());
     modules.add(SetThreadNameFilter.module());
     modules.add(AllRequestFilter.module());
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 269d1c4..b50707b 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -66,6 +66,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -97,6 +98,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
 import com.google.gerrit.json.OutputFormat;
@@ -204,6 +206,7 @@
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
   @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
+  @VisibleForTesting public static final String X_GERRIT_UPDATED_REF = "X-Gerrit-UpdatedRef";
 
   private static final String X_REQUESTED_WITH = "X-Requested-With";
   private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
@@ -593,6 +596,8 @@
               throw new ResourceNotFoundException();
             }
 
+            setXGerritUpdatedRefResponseHeaders(req, res);
+
             if (response instanceof Response.Redirect) {
               CacheHeaders.setNotCacheable(res);
               String location = ((Response.Redirect) response).location();
@@ -753,6 +758,33 @@
     }
   }
 
+  /**
+   * Fill in the refs that were updated during this request in the response header. The updated refs
+   * will be in the form of "project~ref~updated_SHA-1".
+   */
+  private void setXGerritUpdatedRefResponseHeaders(
+      HttpServletRequest request, HttpServletResponse response) {
+    for (GitReferenceUpdatedListener.Event refUpdate :
+        globals.webSession.get().getRefUpdatedEvents()) {
+      String refUpdateFormat =
+          String.format(
+              "%s~%s~%s~%s",
+              // encode the project and ref names since they may contain `~`
+              Url.encode(refUpdate.getProjectName()),
+              Url.encode(refUpdate.getRefName()),
+              refUpdate.getOldObjectId(),
+              refUpdate.getNewObjectId());
+
+      if (isRead(request)) {
+        logger.atWarning().log(
+            "request %s performed a ref update %s although the request is a READ request",
+            request.getRequestURL().toString(), refUpdateFormat);
+      }
+      response.addHeader(X_GERRIT_UPDATED_REF, refUpdateFormat);
+    }
+    globals.webSession.get().resetRefUpdatedEvents();
+  }
+
   private String getEtagWithRetry(
       HttpServletRequest req,
       TraceContext traceContext,
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 07bab24..29c5788 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.HttpdModule;
 import com.google.gerrit.httpd.RequestCleanupFilter;
 import com.google.gerrit.httpd.RequestContextFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
@@ -580,6 +581,7 @@
     modules.add(H2CacheBasedWebSession.module());
     modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(HttpdModule.class));
     if (sshd) {
       modules.add(new ProjectQoSFilter.Module());
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index bc45460..4d90492 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -14,15 +14,25 @@
 
 package com.google.gerrit.acceptance.rest;
 
+import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.X_GERRIT_UPDATED_REF;
 import static org.apache.http.HttpStatus.SC_OK;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import java.io.IOException;
+import java.util.List;
 import java.util.regex.Pattern;
 import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
 public class RestApiServletIT extends AbstractDaemonTest {
@@ -61,6 +71,200 @@
         .containsMatch(ANY_SPACE);
   }
 
+  @Test
+  public void xGerritUpdatedRefNotSetForReadRequests() throws Exception {
+    RestResponse response = adminRestSession.getWithHeader(ANY_REST_API, ACCEPT_STAR_HEADER);
+    assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF)).isNull();
+  }
+
+  @Test
+  public void xGerritUpdatedRefSetForDifferentWriteRequests() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+    String project = change.getChange().project().get();
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+
+    RestResponse response =
+        adminRestSession.putWithHeader(
+            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("A");
+    ObjectId firstMetaRefSha1 = getMetaRefSha1(change);
+
+    // Meta ref updated because of topic update.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF))
+        .isEqualTo(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                firstMetaRefSha1.getName()));
+
+    response =
+        adminRestSession.putWithHeader(
+            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "B");
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("B");
+
+    ObjectId secondMetaRefSha1 = getMetaRefSha1(change);
+
+    // Meta ref updated again because of another topic update.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF))
+        .isEqualTo(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                firstMetaRefSha1.getName(),
+                secondMetaRefSha1.getName()));
+
+    // Ensure the meta ref SHA-1 changed for the project~metaRef which means we return different
+    // X-Gerrit-UpdatedRef headers.
+    assertThat(secondMetaRefSha1).isNotEqualTo(firstMetaRefSha1);
+  }
+
+  @Test
+  public void xGerritUpdatedRefDeleted() throws Exception {
+    Result change = createChange();
+    String project = change.getChange().project().get();
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+    String patchSetRef = RefNames.patchSetRef(change.getPatchSetId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+    ObjectId originalchangeRefSha1 = change.getCommit().getId();
+
+    RestResponse response = adminRestSession.delete("/changes/" + change.getChangeId());
+    response.assertNoContent();
+
+    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+    // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
+    assertThat(headers)
+        .containsExactly(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                ObjectId.zeroId().getName()),
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(patchSetRef),
+                originalchangeRefSha1.getName(),
+                ObjectId.zeroId().getName()));
+  }
+
+  @Test
+  public void xGerritUpdatedRefWithProjectNameContainingTilde() throws Exception {
+    Project.NameKey project = createProjectOverAPI("~~pr~oje~ct~~~~", null, true, null);
+    Result change = createChange(cloneProject(project, admin));
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+    String patchSetRef = RefNames.patchSetRef(change.getPatchSetId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+    ObjectId originalchangeRefSha1 = change.getCommit().getId();
+
+    RestResponse response = adminRestSession.delete("/changes/" + change.getChangeId());
+    response.assertNoContent();
+
+    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+    // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
+    assertThat(headers)
+        .containsExactly(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project.get()),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                ObjectId.zeroId().getName()),
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project.get()),
+                Url.encode(patchSetRef),
+                originalchangeRefSha1.getName(),
+                ObjectId.zeroId().getName()));
+
+    // Ensures ~ gets encoded to %7E.
+    assertThat(Url.encode(project.get())).endsWith("%7E%7Epr%7Eoje%7Ect%7E%7E%7E%7E");
+  }
+
+  @Test
+  public void xGerritUpdatedRefSetMultipleHeadersForSubmit() throws Exception {
+    Result change1 = createChange();
+    Result change2 = createChange();
+    String metaRef1 = RefNames.changeMetaRef(change1.getChange().getId());
+    String metaRef2 = RefNames.changeMetaRef(change2.getChange().getId());
+
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.approve());
+
+    Project.NameKey project = change1.getChange().project();
+
+    try (Repository repository = repoManager.openRepository(project)) {
+      ObjectId originalFirstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId originalSecondMetaRefSha1 = getMetaRefSha1(change2);
+      ObjectId originalDestinationBranchSha1 =
+          repository.resolve(change1.getChange().change().getDest().branch());
+
+      RestResponse response =
+          adminRestSession.post("/changes/" + change2.getChangeId() + "/submit");
+      response.assertOK();
+
+      ObjectId firstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId secondMetaRefSha1 = getMetaRefSha1(change2);
+
+      List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+      String branch = change1.getChange().change().getDest().branch();
+      String branchSha1 =
+          repository
+              .getRefDatabase()
+              .exactRef(change1.getChange().change().getDest().branch())
+              .getObjectId()
+              .name();
+
+      // During submit, all relevant meta refs of the latest patchset are updated + the destination
+      // branch/es.
+      // TODO(paiking): This doesn't work well for torn submissions: If the changes were in
+      // different projects in the same topic, and we tried to submit those changes together, it's
+      // possible that the first submission only submitted one of the changes, and then the retry
+      // submitted the other change. If that happens, when the user retries, they will not get the
+      // meta ref updates for the change that got submitted on the previous submission attempt.
+      // Ideally, submit should be idempotent and always return all meta refs on all submission
+      // attempts.
+      assertThat(headers)
+          .containsExactly(
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(metaRef1),
+                  originalFirstMetaRefSha1.getName(),
+                  firstMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(metaRef2),
+                  originalSecondMetaRefSha1.getName(),
+                  secondMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(branch),
+                  originalDestinationBranchSha1.getName(),
+                  branchSha1));
+    }
+  }
+
+  private ObjectId getMetaRefSha1(Result change) {
+    return change.getChange().notes().getRevision();
+  }
+
   private RestResponse prettyJsonRestResponse(String ppArgument, int ppValue) throws Exception {
     RestResponse response =
         adminRestSession.getWithHeader(