Initial commit of the closed refs filtering module

The new refsfilter Gerrit module allows to reduce the footprint
of the Git refs advertizing phase by hiding all the refs associated
to non-live changes.

Typical scenario is a CI build system: as closed changes (merged, abandoned)
are not live anymore and would never be built, they are filtered out of
the Git advertizing phase, saving a lot of processing from both client
and server side.

Another typical scenario is Gerrit slave replication for development
purposes. Typically you do not work anymore on closed changes and
there is little or even no value in getting those refs advertized
and replicated to remote sites.

Change-Id: I4636bc79bfc7b3290c4d491a638cacc8318d43a7
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c8932d1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.class
+*.swp
+*.jar
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..b20f6fb
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,36 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+load(
+    "//tools/bzl:plugin.bzl",
+    "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
+    "gerrit_plugin",
+)
+
+gerrit_plugin(
+    name = "git-refs-filter",
+    srcs = glob(["src/main/java/**/*.java"]),
+    resources = glob(["src/main/resources/**/*"]),
+)
+
+junit_tests(
+    name = "git_refs_filter_tests",
+    srcs = glob(
+        ["src/test/java/**/*Test.java"],
+        exclude = ["src/test/java/**/Abstract*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
+        ":git-refs-filter__plugin",
+        ":git_refs_filter__plugin_test_deps",
+    ],
+)
+
+java_library(
+    name = "git_refs_filter__plugin_test_deps",
+    testonly = 1,
+    srcs = glob(["src/test/java/**/Abstract*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
+        ":git-refs-filter__plugin",
+    ],
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0c5c8b6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# Git Ref filter module for Gerrit
+
+Gerrit lib module to allow filtering out refs in the Git advertizing
+protocol phase.
+
+## How to build
+
+Build this module as it was a Gerrit plugin:
+
+- Clone Gerrit source tree
+- Clone the git-refs-filter source tree
+- Link the ```git-refs-filter``` directory to Gerrit ```/plugins/git-refs-filter```
+- From Gerrit source tree run ```bazel build plugins/git-refs-filter```
+- And for running tests ```bazel test plugins/git-refs-filter:git_refs_filter_tests```
+- The ```git-refs-filter.jar``` module is generated under ```/bazel-genfiles/plugins/git-refs-filter/```
+
+## How install
+
+Copy ```git-refs-filter.jar``` library to Gerrit ```/lib``` and add the following
+one extra settings to ```gerrit.config```:
+
+```
+[gerrit]
+  installModule = com.googlesource.gerrit.modules.gitrefsfilter.RefsFilterModule
+```
+
+## How to configure filtering
+
+The refsfilter module defines a new global capability called "Filter out closed changes refs".
+By default the capability isn't assigned to any user or group, thus the module installation
+has no side effects.
+
+To enable a group of users of getting a "filtered list" of refs (e.g. CI jobs):
+- Define a new group of users (e.g. Builders)
+- Add a user to that group (e.g. Add 'jenkins' to the Builders group)
+- Go to the All-Projects ACLs, add the "Filter out closed changes refs" and assign to the group (e.g. Builders)
+
+*NOTE* Gerrit makes a super-simplified ACL evaluation if all the projects are globally readable (e.g. project has
+a READ rule to refs/*). To enable the closed changes filtering you need to disable any global read rule
+for the group that needs refs filtering.
+
diff --git a/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/FilterRefsCapability.java b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/FilterRefsCapability.java
new file mode 100644
index 0000000..50d5545
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/FilterRefsCapability.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 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.googlesource.gerrit.modules.gitrefsfilter;
+
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+
+public class FilterRefsCapability extends CapabilityDefinition {
+
+  public static final String HIDE_CLOSED_CHANGES_REFS = "hideClosedChangesRefs";
+
+  @Override
+  public String getDescription() {
+    return "Filter out closed changes refs";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/FilterRefsPermission.java b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/FilterRefsPermission.java
new file mode 100644
index 0000000..c6c0f4e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/FilterRefsPermission.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 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.googlesource.gerrit.modules.gitrefsfilter;
+
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.inject.Inject;
+
+public class FilterRefsPermission extends PluginPermission {
+
+  @Inject
+  public FilterRefsPermission() {
+    super("gerrit", FilterRefsCapability.HIDE_CLOSED_CHANGES_REFS, false);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/ForProjectWrapper.java b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/ForProjectWrapper.java
new file mode 100644
index 0000000..508fd85
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/ForProjectWrapper.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2018 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.googlesource.gerrit.modules.gitrefsfilter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+public class ForProjectWrapper extends ForProject {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ForProject defaultForProject;
+  private final NameKey project;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final Provider<ReviewDb> dbProvider;
+
+  public interface Factory {
+    ForProjectWrapper get(ForProject defaultForProject, Project.NameKey project);
+  }
+
+  @Inject
+  public ForProjectWrapper(
+      ChangeNotes.Factory changeNotesFactory,
+      Provider<ReviewDb> dbProvider,
+      @Assisted ForProject defaultForProject,
+      @Assisted Project.NameKey project) {
+    this.defaultForProject = defaultForProject;
+    this.project = project;
+    this.changeNotesFactory = changeNotesFactory;
+    this.dbProvider = dbProvider;
+  }
+
+  @Override
+  public ForRef ref(String ref) {
+    return defaultForProject.ref(ref);
+  }
+
+  @Override
+  public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
+    defaultForProject.check(perm);
+  }
+
+  @Override
+  public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+      throws PermissionBackendException {
+    return defaultForProject.test(permSet);
+  }
+
+  @Override
+  public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+      throws PermissionBackendException {
+    Map<String, Ref> filteredRefs = new HashMap<>();
+    Map<String, Ref> defaultFilteredRefs =
+        defaultForProject.filter(refs, repo, opts); // FIXME: can we filter the closed refs here?
+    Set<String> openChangesRefs = openChangesByScan(repo);
+
+    for (String changeKey : defaultFilteredRefs.keySet()) {
+      if (!isChangeRef(changeKey)
+          || (isOpen(openChangesRefs, changeKey) && !isChangeMetaRef(changeKey))) {
+        filteredRefs.put(changeKey, defaultFilteredRefs.get(changeKey));
+      }
+    }
+
+    return filteredRefs;
+  }
+
+  private boolean isChangeRef(String changeKey) {
+    return changeKey.startsWith("refs/changes");
+  }
+
+  private boolean isChangeMetaRef(String changeKey) {
+    return isChangeRef(changeKey) && changeKey.endsWith("/meta");
+  }
+
+  private boolean isOpen(Set<String> openChangesRefs, String changeKey) {
+    // Parse changeKey as refs/changes/NN/<change num>/PP
+    String changeRefWithoutPatchset = changeKey.substring(0, changeKey.lastIndexOf('/') + 1);
+    return openChangesRefs.contains(changeRefWithoutPatchset);
+  }
+
+  @Override
+  public BooleanCondition testCond(ProjectPermission perm) {
+    return defaultForProject.testCond(perm);
+  }
+
+  @Override
+  public String resourcePath() {
+    return defaultForProject.resourcePath();
+  }
+
+  private Set<String> openChangesByScan(Repository repo) {
+    Set<String> result = new HashSet<>();
+    Stream<ChangeNotesResult> s;
+    try {
+      s = changeNotesFactory.scan(repo, dbProvider.get(), project);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot load changes for project %s, assuming no changes are visible", project);
+      return Collections.emptySet();
+    }
+
+    for (ChangeNotesResult notesResult : s.collect(toImmutableList())) {
+      Change change = toNotes(notesResult).getChange();
+      if (change.getStatus().isOpen()) {
+        result.add(change.getId().toRefPrefix());
+      }
+    }
+    return result;
+  }
+
+  @Nullable
+  private ChangeNotes toNotes(ChangeNotesResult r) {
+    if (r.error().isPresent()) {
+      logger.atWarning().withCause(r.error().get()).log(
+          "Failed to load change %s in %s", r.id(), project);
+      return null;
+    }
+    return r.notes();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/RefsFilterModule.java b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/RefsFilterModule.java
new file mode 100644
index 0000000..98ce862
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/RefsFilterModule.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2018 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.googlesource.gerrit.modules.gitrefsfilter;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+public class RefsFilterModule extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(WithUserWrapper.class, WithUserWrapper.class)
+            .build(WithUserWrapper.Factory.class));
+
+    install(
+        new FactoryModuleBuilder()
+            .implement(ForProjectWrapper.class, ForProjectWrapper.class)
+            .build(ForProjectWrapper.Factory.class));
+
+    bind(PermissionBackend.class).to(RefsFilterPermissionBackend.class).in(Scopes.SINGLETON);
+
+    bind(CapabilityDefinition.class)
+        .annotatedWith(Exports.named(FilterRefsCapability.HIDE_CLOSED_CHANGES_REFS))
+        .to(FilterRefsCapability.class)
+        .in(Scopes.SINGLETON);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/RefsFilterPermissionBackend.java b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/RefsFilterPermissionBackend.java
new file mode 100644
index 0000000..614e9b6
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/RefsFilterPermissionBackend.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2018 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.googlesource.gerrit.modules.gitrefsfilter;
+
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+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.PermissionBackendCondition;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Set;
+
+public class RefsFilterPermissionBackend extends PermissionBackend {
+  private final DefaultPermissionBackend defaultBackend;
+  private final WithUserWrapper.Factory filteredRefsUserFactory;
+  private final Provider<CurrentUser> currentUserProvider;
+
+  @Inject
+  RefsFilterPermissionBackend(
+      DefaultPermissionBackend defaultBackend,
+      WithUserWrapper.Factory filteredRefsUserFactory,
+      Provider<CurrentUser> currentUserProvider) {
+    this.defaultBackend = defaultBackend;
+    this.filteredRefsUserFactory = filteredRefsUserFactory;
+    this.currentUserProvider = currentUserProvider;
+  }
+
+  @Override
+  public WithUser user(CurrentUser user) {
+    return filteredRefsUserFactory.get(defaultBackend.user(user));
+  }
+
+  @Override
+  public WithUser currentUser() {
+    return user(currentUserProvider.get());
+  }
+
+  @Override
+  public WithUser absentUser(Account.Id id) {
+    return defaultBackend.absentUser(id);
+  }
+
+  @Override
+  public boolean usesDefaultCapabilities() {
+    return defaultBackend.usesDefaultCapabilities();
+  }
+
+  @Override
+  public void checkUsesDefaultCapabilities() throws ResourceNotFoundException {
+    defaultBackend.checkUsesDefaultCapabilities();
+  }
+
+  @Override
+  public void bulkEvaluateTest(Set<PermissionBackendCondition> conds) {
+    defaultBackend.bulkEvaluateTest(conds);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/WithUserWrapper.java b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/WithUserWrapper.java
new file mode 100644
index 0000000..a86c817
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/modules/gitrefsfilter/WithUserWrapper.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2018 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.googlesource.gerrit.modules.gitrefsfilter;
+
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.WithUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Set;
+
+public class WithUserWrapper extends WithUser {
+  private final FilterRefsPermission filterRefsPermission;
+  private final WithUser defaultWithUser;
+  private final ForProjectWrapper.Factory forProjectFactory;
+
+  public interface Factory {
+    WithUserWrapper get(WithUser defaultWithUser);
+  }
+
+  @Inject
+  WithUserWrapper(
+      FilterRefsPermission filterRefsPermission,
+      ForProjectWrapper.Factory forProjectFactory,
+      @Assisted WithUser defaultWithUser) {
+    this.filterRefsPermission = filterRefsPermission;
+    this.defaultWithUser = defaultWithUser;
+    this.forProjectFactory = forProjectFactory;
+  }
+
+  @Override
+  public ForProject project(NameKey project) {
+    ForProject defaultWithProject = defaultWithUser.project(project);
+    if (defaultWithUser.testOrFalse(filterRefsPermission)) {
+      return forProjectFactory.get(defaultWithProject, project);
+    }
+    return defaultWithProject;
+  }
+
+  @Override
+  public void check(GlobalOrPluginPermission perm)
+      throws AuthException, PermissionBackendException {
+    defaultWithUser.check(perm);
+  }
+
+  @Override
+  public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+      throws PermissionBackendException {
+    return defaultWithUser.test(permSet);
+  }
+
+  @Override
+  public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+    return defaultWithUser.testCond(perm);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/libmodule/plugins/test/AbstractGitDaemonTest.java b/src/test/java/com/googlesource/gerrit/libmodule/plugins/test/AbstractGitDaemonTest.java
new file mode 100644
index 0000000..32f8ac4
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/libmodule/plugins/test/AbstractGitDaemonTest.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2018 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.googlesource.gerrit.libmodule.plugins.test;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.googlesource.gerrit.modules.gitrefsfilter.FilterRefsCapability;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.FetchResult;
+import org.eclipse.jgit.util.FS;
+
+abstract class AbstractGitDaemonTest extends AbstractDaemonTest {
+  private static final String REFS_CHANGES = "+refs/changes/*:refs/remotes/origin/*";
+
+  protected void createChangeAndAbandon() throws Exception, RestApiException {
+    setApiUser(admin);
+    createChange();
+    int changeNum = changeNumOfRef(getChangesRefsAs(admin).get(0));
+    gApi.changes().id(changeNum).abandon();
+  }
+
+  protected void createFilteredRefsGroup() throws Exception {
+    setApiUser(admin);
+    String group = name("filtered-refs-group");
+    GroupInput in = new GroupInput();
+    in.name = group;
+    GroupApi groupApi = gApi.groups().create(in);
+    groupApi.addMembers(user.username);
+
+    setApiUser(user);
+    groupApi.removeMembers(admin.username);
+    String groupId = groupApi.detail().id;
+
+    allowGlobalCapabilities(
+        AccountGroup.UUID.parse(groupId),
+        "gerrit-" + FilterRefsCapability.HIDE_CLOSED_CHANGES_REFS);
+  }
+
+  protected List<Ref> getChangesRefsAs(TestAccount testAccount) throws Exception {
+    return getRefs(cloneProjectChangesRefs(testAccount));
+  }
+
+  protected TestRepository<InMemoryRepository> cloneProjectChangesRefs(TestAccount testAccount)
+      throws Exception {
+    DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
+
+    FS fs = FS.detect();
+
+    // Avoid leaking user state into our tests.
+    fs.setUserHome(null);
+
+    InMemoryRepository dest =
+        new InMemoryRepository.Builder()
+            .setRepositoryDescription(desc)
+            // SshTransport depends on a real FS to read ~/.ssh/config, but
+            // InMemoryRepository by default uses a null FS.
+            // TODO(dborowitz): Remove when we no longer depend on SSH.
+            .setFS(fs)
+            .build();
+    Config cfg = dest.getConfig();
+    String uri = registerRepoConnection(project, testAccount);
+    cfg.setString("remote", "origin", "url", uri);
+    cfg.setString("remote", "origin", "fetch", REFS_CHANGES);
+    TestRepository<InMemoryRepository> testRepo = GitUtil.newTestRepository(dest);
+    FetchResult result = testRepo.git().fetch().setRemote("origin").call();
+    String originMaster = "refs/remotes/origin/master";
+    if (result.getTrackingRefUpdate(originMaster) != null) {
+      testRepo.reset(originMaster);
+    }
+    return testRepo;
+  }
+
+  protected List<Ref> getRefs(TestRepository<InMemoryRepository> repo) throws IOException {
+    return repo.getRepository().getRefDatabase().getRefs();
+  }
+
+  protected int changeNumOfRef(Ref ref) {
+    /*
+     * refName is refs/remotes/origin/<NN>/<change-num>
+     */
+    return Integer.parseInt(ref.getName().split("/")[4]);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/libmodule/plugins/test/GitRefsFilterTest.java b/src/test/java/com/googlesource/gerrit/libmodule/plugins/test/GitRefsFilterTest.java
new file mode 100644
index 0000000..145c7c9
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/libmodule/plugins/test/GitRefsFilterTest.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2018 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.googlesource.gerrit.libmodule.plugins.test;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.inject.Module;
+import com.googlesource.gerrit.modules.gitrefsfilter.RefsFilterModule;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@Sandboxed
+public class GitRefsFilterTest extends AbstractGitDaemonTest {
+
+  @Override
+  public Module createModule() {
+    return new RefsFilterModule();
+  }
+
+  @Before
+  public void setup() throws Exception {
+    createFilteredRefsGroup();
+  }
+
+  @Test
+  public void testUserWithFilterOutCapabilityShouldNotSeeAbandonedChangesRefs() throws Exception {
+    createChangeAndAbandon();
+
+    assertThat(getRefs(cloneProjectChangesRefs(user))).hasSize(0);
+  }
+
+  @Test
+  public void testUserWithFilterOutCapabilityShouldSeeOpenChangesRefs() throws Exception {
+    createChange();
+
+    assertThat(getRefs(cloneProjectChangesRefs(user))).hasSize(1);
+  }
+
+  @Test
+  public void testAdminUserShouldSeeAbandonedChangesRefs() throws Exception {
+    createChangeAndAbandon();
+
+    assertThat(getRefs(cloneProjectChangesRefs(admin))).hasSize(1);
+  }
+}