Merge "Compute diff URLs in the view model"
diff --git a/java/com/google/gerrit/acceptance/TestConfigRule.java b/java/com/google/gerrit/acceptance/TestConfigRule.java
index a7f051a..e2ae416 100644
--- a/java/com/google/gerrit/acceptance/TestConfigRule.java
+++ b/java/com/google/gerrit/acceptance/TestConfigRule.java
@@ -47,8 +47,11 @@
       @Override
       public void evaluate() throws Throwable {
         setTestConfigFromDescription(description);
-        statement.evaluate();
-        clear();
+        try {
+          statement.evaluate();
+        } finally {
+          clear();
+        }
       }
     };
   }
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index d9f1c09..5d15a56 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -24,6 +24,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Optional;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -58,11 +60,13 @@
     int commentIdx = uriPath.indexOf("/comment");
     String idString = commentIdx == -1 ? uriPath : uriPath.substring(0, commentIdx);
 
-    if (idString.endsWith("/")) {
-      idString = idString.substring(0, idString.length() - 1);
-    }
+    List<String> uriSegments = Arrays.stream(idString.split("/")).toList();
+
+    idString = uriSegments.get(0);
+    String psString = (uriSegments.size() > 1) ? uriSegments.get(1) : null;
+
     Optional<Change.Id> id = Change.Id.tryParse(idString);
-    if (!id.isPresent()) {
+    if (id.isEmpty()) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
     }
@@ -81,6 +85,8 @@
     if (commentIdx > -1) {
       // path already contain a trailing /, hence we start from "commentIdx + 1"
       path = path + uriPath.substring(commentIdx + 1);
+    } else if (psString != null) {
+      path += psString;
     }
     UrlModule.toGerrit(path, req, rsp);
   }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 7a100c7..1d62efe 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -73,6 +73,8 @@
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
     serveRegex("^(?:/c)?/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^(?:/c)?/([1-9][0-9]*)/([1-9][0-9]*)/?$")
+        .with(NumericChangeIdRedirectServlet.class);
     serveRegex("^(?:/c)?/([1-9][0-9]*)/comment/\\w+/?$").with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 68061bd..e515dcc 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -1592,6 +1592,15 @@
       } else {
         rejectProhibited(cmd, err.get());
       }
+      if (ObjectId.zeroId().equals(cmd.getOldId())) {
+        // Git CLI sends DELETE 0..0 0...0 when the server doesn't send the deleted ref during
+        // negotiation. The server usually doesn't send it when ref doesn't exist or when it
+        // is not visible to a caller - so the message that the ref doesn't exist should be ok
+        // here.
+        // Without this check, such delete always fails with the "internal error" message, caused
+        // by the checkArgument in the  ChainedReceiveCommands#add.
+        reject(cmd, String.format("The ref %s doesn't exist", cmd.getRefName()));
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 5c7d524..2311240 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -356,6 +356,7 @@
           throw new CommitValidationException(MISSING_CHANGE_ID_MSG, messages);
         }
       } else if (idList.size() > 1) {
+        messages.add(getMultipleChangeIdsErrorMsg(idList));
         throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
       } else {
         String v = idList.get(0).trim();
@@ -391,6 +392,24 @@
           ValidationMessage.Type.ERROR);
     }
 
+    private CommitValidationMessage getMultipleChangeIdsErrorMsg(List<String> idList) {
+      return new CommitValidationMessage(
+          MULTIPLE_CHANGE_ID_MSG
+              + "\n"
+              + "\nHint: the following Change-Ids were found:\n"
+              + idList.stream()
+                  .map(
+                      id ->
+                          "* "
+                              + id
+                              + " ["
+                              + (CHANGE_ID.matcher(id.trim()).matches() ? "VALID" : "INVALID")
+                              + "]")
+                  .collect(Collectors.joining("\n"))
+              + "\n",
+          ValidationMessage.Type.ERROR);
+    }
+
     private String getCommitMessageHookInstallationHint() {
       if (installCommitMsgHookCommand != null) {
         return installCommitMsgHookCommand;
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index ac9ac98..dbdd26f 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -21,6 +21,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelType;
@@ -261,6 +263,19 @@
       }
       return allowed;
     }
+
+    /**
+     * Additional filter for changes query for reducing the cardinality of the results for current
+     * user.
+     *
+     * @return additional query filter to add to all user's change queries, null if no filters are
+     *     required.
+     * @since 3.11
+     */
+    @UsedAt(UsedAt.Project.MODULE_VIRTUALHOST)
+    public @Nullable String filterQueryChanges() {
+      return null;
+    }
   }
 
   /** PermissionBackend scoped to a user and project. */
diff --git a/java/com/google/gerrit/server/plugins/Plugin.java b/java/com/google/gerrit/server/plugins/Plugin.java
index b5ff041..3de7e27 100644
--- a/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/java/com/google/gerrit/server/plugins/Plugin.java
@@ -78,7 +78,7 @@
 
   protected LifecycleManager manager;
 
-  private List<ReloadableRegistrationHandle<?>> reloadableHandles;
+  protected List<ReloadableRegistrationHandle<?>> reloadableHandles;
 
   public Plugin(
       String name, Path srcPath, PluginUser pluginUser, FileSnapshot snapshot, ApiType apiType) {
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 0726913..8cfc6f3 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -355,6 +355,23 @@
       reattachItem(old, sysItems, newPlugin.getSysInjector(), newPlugin);
       reattachItem(old, sshItems, newPlugin.getSshInjector(), newPlugin);
       reattachItem(old, httpItems, newPlugin.getHttpInjector(), newPlugin);
+
+      apiInjector = Optional.ofNullable(newPlugin.getApiInjector()).orElse(apiInjector);
+
+      if (apiInjector != null) {
+        apiItems.putAll(dynamicItemsOf(apiInjector));
+        apiSets.putAll(dynamicSetsOf(apiInjector));
+        apiMaps.putAll(dynamicMapsOf(apiInjector));
+
+        ImmutableList<Injector> allPluginInjectors =
+            listOfInjectors(
+                newPlugin.getSysInjector(),
+                newPlugin.getSshInjector(),
+                newPlugin.getHttpInjector());
+        allPluginInjectors.forEach(i -> reattachItem(old, apiItems, i, newPlugin));
+        allPluginInjectors.forEach(i -> reattachSet(old, apiSets, i, newPlugin));
+        allPluginInjectors.forEach(i -> reattachMap(old, apiMaps, i, newPlugin));
+      }
     } finally {
       exit(oldContext);
     }
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index bd83b98..fb7cbe2 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -58,7 +58,6 @@
   private Injector sshInjector;
   private Injector httpInjector;
   private LifecycleManager serverManager;
-  private List<ReloadableRegistrationHandle<?>> reloadableHandles;
 
   private Optional<Module> apiModule = Optional.empty();
 
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 812711a..d05cbf6 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -155,6 +156,7 @@
       throws BadRequestException, AuthException, PermissionBackendException {
     List<List<ChangeInfo>> out;
     try {
+      applyPermissionBackendFilter();
       out = query();
     } catch (QueryRequiresAuthException e) {
       throw new AuthException("Must be signed-in to use this operator", e);
@@ -165,6 +167,22 @@
     return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
+  private void applyPermissionBackendFilter() {
+    String queryFilter = permissionBackend.currentUser().filterQueryChanges();
+    if (Strings.isNullOrEmpty(queryFilter)) {
+      return;
+    }
+
+    if (queries == null || queries.isEmpty()) {
+      addQuery(queryFilter);
+      return;
+    }
+
+    for (int i = 0; i < queries.size(); i++) {
+      queries.set(i, queries.get(i) + " " + queryFilter);
+    }
+  }
+
   private List<List<ChangeInfo>> query()
       throws BadRequestException, QueryParseException, PermissionBackendException {
     ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
new file mode 100644
index 0000000..07d32fe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2024 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.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.DefaultPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class QueryChangesFilterPermissionBackendIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Singleton
+  public static class TestPermissionBackend extends PermissionBackend {
+    private final DefaultPermissionBackend defaultPermissionBackend;
+    private final AtomicReference<String> extraQueryFilter;
+
+    public static class Module extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(PermissionBackend.class).to(TestPermissionBackend.class).in(Scopes.SINGLETON);
+      }
+    }
+
+    @Inject
+    TestPermissionBackend(DefaultPermissionBackend defaultPermissionBackend) {
+      this.defaultPermissionBackend = defaultPermissionBackend;
+      this.extraQueryFilter = new AtomicReference<>();
+    }
+
+    @Override
+    public WithUser currentUser() {
+      return new TestPermissionWithUser(defaultPermissionBackend.currentUser());
+    }
+
+    @Override
+    public WithUser user(CurrentUser user) {
+      return new TestPermissionWithUser(defaultPermissionBackend.user(user));
+    }
+
+    @Override
+    public WithUser absentUser(Account.Id id) {
+      return new TestPermissionWithUser(defaultPermissionBackend.absentUser(id));
+    }
+
+    public String getExtraQueryFilter() {
+      return extraQueryFilter.get();
+    }
+
+    public void setExtraQueryFilter(String extraQueryFilter) {
+      this.extraQueryFilter.set(extraQueryFilter);
+    }
+
+    class TestPermissionWithUser extends WithUser {
+
+      private final WithUser defaultPermissioBackendWithUser;
+
+      TestPermissionWithUser(WithUser defaultPermissioBackendWithUser) {
+        this.defaultPermissioBackendWithUser = defaultPermissioBackendWithUser;
+      }
+
+      @Override
+      public ForProject project(Project.NameKey project) {
+        return defaultPermissioBackendWithUser.project(project);
+      }
+
+      @Override
+      public void check(GlobalOrPluginPermission perm)
+          throws AuthException, PermissionBackendException {
+        defaultPermissioBackendWithUser.check(perm);
+      }
+
+      @Override
+      public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+          throws PermissionBackendException {
+        return defaultPermissioBackendWithUser.test(permSet);
+      }
+
+      @Override
+      public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+        return defaultPermissioBackendWithUser.testCond(perm);
+      }
+
+      @Override
+      public String filterQueryChanges() {
+        return extraQueryFilter.get();
+      }
+    }
+  }
+
+  @Override
+  public Module createModule() {
+    return new TestPermissionBackend.Module();
+  }
+
+  @Test
+  public void filterHidenProjectByAuthenticationBackend() throws Exception {
+    String projectChangeId = createChange().getChangeId();
+
+    Project.NameKey hiddenProject = projectOperations.newProject().create();
+    TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
+    createChange(hiddenRepo);
+
+    assertThat(gApi.changes().query().get()).hasSize(2);
+
+    server
+        .getTestInjector()
+        .getInstance(TestPermissionBackend.class)
+        .setExtraQueryFilter("-project:" + hiddenProject);
+    List<ChangeInfo> projectChanges = gApi.changes().query().get();
+    assertThat(projectChanges.stream().map(c -> c.changeId)).containsExactly(projectChangeId);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 5c1f7c2..20554ac 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.regex.Pattern;
@@ -449,6 +450,30 @@
   }
 
   @Test
+  public void testNumericChangeIdWithPSRedirectWithPrefix() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    int psNumber = changeData.currentPatchSet().id().get();
+    int changeNumber = changeData.getId().get();
+
+    String redirectUri = String.format("/c/%s/+/%d/%d", project.get(), changeNumber, psNumber);
+    anonymousRestSession
+        .get(String.format("/c/%d/%d", changeNumber, psNumber))
+        .assertTemporaryRedirect(redirectUri);
+  }
+
+  @Test
+  public void testNumericChangeIdWithPSAndSlashRedirectWithPrefix() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    int psNumber = changeData.currentPatchSet().id().get();
+    int changeNumber = changeData.getId().get();
+
+    String redirectUri = String.format("/c/%s/+/%d/%d", project.get(), changeNumber, psNumber);
+    anonymousRestSession
+        .get(String.format("/c/%d/%d/", changeNumber, psNumber))
+        .assertTemporaryRedirect(redirectUri);
+  }
+
+  @Test
   public void testCommentLinkWithoutPrefixRedirects() throws Exception {
     int changeNumber = createChange().getChange().getId().get();
     String commentId = "ff3303fd_8341647b";
diff --git a/lib/fonts/material-icons.woff2 b/lib/fonts/material-icons.woff2
index 11074da..4fd4a47 100644
--- a/lib/fonts/material-icons.woff2
+++ b/lib/fonts/material-icons.woff2
Binary files differ
diff --git a/plugins/replication b/plugins/replication
index 56b8ffb..aacb8b2 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 56b8ffbab5bf619c0b6b5d44f0255fd41b9e1c89
+Subproject commit aacb8b2a20267e88ccda811d27293bac66e2006b
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 1a0eeea..7edfe2b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -208,7 +208,7 @@
       return;
 
     return html`
-      <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
+      <h2 class="heading-2">${this.repoConfig?.actions['gc']?.label}</h2>
       <gr-button
         title=${this.repoConfig?.actions['gc']?.title || ''}
         ?loading=${this.runningGC}
diff --git a/polygerrit-ui/app/styles/material-icons.css b/polygerrit-ui/app/styles/material-icons.css
index 4c0313c..0cce879 100644
--- a/polygerrit-ui/app/styles/material-icons.css
+++ b/polygerrit-ui/app/styles/material-icons.css
@@ -1,8 +1,8 @@
 /**
- * This file has been produced by downloading this file on Sep 6, 2022:
+ * This file has been produced by downloading this file on June 11, 2024:
  * https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0
- * The corresponding ttf file was downloaded on Sep 6, 2022 from:
- * https://fonts.gstatic.com/s/materialsymbolsoutlined/v51/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0J1Llf.woff2
+ * The corresponding ttf file was downloaded on June 11, 2024 from:
+ * https://fonts.gstatic.com/s/materialsymbolsoutlined/v192/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0.woff2
  */
 @font-face {
   font-family: 'Material Symbols Outlined';