Support searching changes which only touch certain file extensions
With the current 'extension' search operator it's possible to find
a) changes that contain at least one file with the given extension,
e.g.: extension:txt
b) changes that contain no file with the given extension,
e.g.: -extension:txt
However sometimes you want to match changes which only contain files of
a given extension, e.g. changes that only touch txt files. This can now
be done with the new 'only_extensions' search operator, e.g.:
only_extensions:txt
It is also possible to specify multiple file extensions. E.g. matching
changes that only touch txt and jpg files can be done by:
only_extensions:jpg,txt
By reversing the 'only_extensions' search operator it is possible to
match changes that not only touch files with certain extensions, e.g.:
-only_extensions:jpg,txt
The order and the case in which the extensions are provided to the
'only_extensions' operator don't matter.
Also extensions can be specified with or without leading '.' (same as
for the 'extension' search operator).
Changes that contain files without file extension can be matched by
including an empty file extension into the file extension list, e.g.:
* changes that only include txt files and files without extension:
only_extensions:,txt
* changes that only contain files without extension:
only_extensions:,
only_extensions:""
Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I3d2b978ed455f835d1dad2daa920be0b0ec2ae36
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index abd2531..589b293 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -297,6 +297,15 @@
Files with no `.` in their name have no extension and cannot be matched with
this operator; use `file:` instead.
+[[onlyextensions]]
+onlyextensions:'EXT_LIST', onlyexts:'EXT_LIST'::
++
+Matches any change touching only files with extensions that are listed in
+'EXT_LIST' (comma-separated list). The matching is done case-insensitive.
+An extension is defined as the portion of the filename following the final `.`.
+Files with no `.` in their name have no extension and can be matched by an
+empty string.
+
[[star]]
star:'LABEL'::
+
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 52dac9d..818a5fb 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -24,6 +24,7 @@
import static com.google.gerrit.index.FieldDef.storedOnly;
import static com.google.gerrit.index.FieldDef.timestamp;
import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
@@ -191,6 +192,29 @@
exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions);
public static Set<String> getExtensions(ChangeData cd) throws OrmException {
+ return extensions(cd).filter(e -> !e.isEmpty()).collect(toSet());
+ }
+
+ /**
+ * File extensions of each file modified in the current patch set as a sorted list. The purpose of
+ * this field is to allow matching changes that only touch files with certain file extensions.
+ */
+ public static final FieldDef<ChangeData, String> ONLY_EXTENSIONS =
+ exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList);
+
+ public static String getAllExtensionsAsList(ChangeData cd) throws OrmException {
+ return extensions(cd).distinct().sorted().collect(joining(","));
+ }
+
+ /**
+ * Returns a stream with all file extensions that are used by files in the given change. A file
+ * extension is defined as the portion of the filename following the final `.`. Files with no `.`
+ * in their name have no extension. For them an empty string is returned as part of the stream.
+ *
+ * <p>If the change contains multiple files with the same extension the extension is returned
+ * multiple times in the stream (once per file).
+ */
+ private static Stream<String> extensions(ChangeData cd) throws OrmException {
try {
return cd.currentFilePaths()
.stream()
@@ -198,9 +222,7 @@
// If we want to find "all Java files", we want to match both .java and .JAVA, even if we
// normally care about case sensitivity. (Whether we should change the existing file/path
// predicates to be case insensitive is a separate question.)
- .map(f -> Files.getFileExtension(f).toLowerCase(Locale.US))
- .filter(e -> !e.isEmpty())
- .collect(toSet());
+ .map(f -> Files.getFileExtension(f).toLowerCase(Locale.US));
} catch (IOException e) {
throw new OrmException(e);
}
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index cd24c92..3a19c2c 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -103,7 +103,9 @@
@Deprecated static final Schema<ChangeData> V51 = schema(V50, ChangeField.TOTAL_COMMENT_COUNT);
- static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
+ @Deprecated static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
+
+ static final Schema<ChangeData> V53 = schema(V52, ChangeField.ONLY_EXTENSIONS);
public static final String NAME = "changes";
public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 45847c8..bdfb45b 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -139,6 +139,7 @@
public static final String FIELD_COMMITTER = "committer";
public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
public static final String FIELD_EXTENSION = "extension";
+ public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
public static final String FIELD_CONFLICTS = "conflicts";
public static final String FIELD_DELETED = "deleted";
public static final String FIELD_DELTA = "delta";
@@ -748,6 +749,20 @@
}
@Operator
+ public Predicate<ChangeData> onlyexts(String extList) throws QueryParseException {
+ return onlyextensions(extList);
+ }
+
+ @Operator
+ public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
+ if (args.getSchema().hasField(ChangeField.ONLY_EXTENSIONS)) {
+ return new FileExtensionListPredicate(extList);
+ }
+ throw new QueryParseException(
+ "'onlyextensions' operator is not supported by change index version");
+ }
+
+ @Operator
public Predicate<ChangeData> label(String name)
throws QueryParseException, OrmException, IOException, ConfigInvalidException {
Set<Account.Id> accounts = null;
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
new file mode 100644
index 0000000..3399338
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2019 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.query.change;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Splitter;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+public class FileExtensionListPredicate extends ChangeIndexPredicate {
+ private static String clean(String extList) {
+ return Splitter.on(',')
+ .splitToList(extList)
+ .stream()
+ .map(FileExtensionPredicate::clean)
+ .distinct()
+ .sorted()
+ .collect(joining(","));
+ }
+
+ FileExtensionListPredicate(String value) {
+ super(ChangeField.ONLY_EXTENSIONS, clean(value));
+ }
+
+ @Override
+ public boolean match(ChangeData cd) throws OrmException {
+ return ChangeField.getAllExtensionsAsList(cd).equals(value);
+ }
+
+ @Override
+ public int getCost() {
+ return 0;
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
index ee5030a..5353f11 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -19,7 +19,7 @@
import java.util.Locale;
public class FileExtensionPredicate extends ChangeIndexPredicate {
- private static String clean(String ext) {
+ static String clean(String ext) {
if (ext.startsWith(".")) {
ext = ext.substring(1);
}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 79eae2a..35abc74 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1390,6 +1390,62 @@
}
@Test
+ public void byOnlyExtensions() throws Exception {
+ if (getSchemaVersion() < 53) {
+ assertMissingField(ChangeField.ONLY_EXTENSIONS);
+ String unsupportedOperatorMessage =
+ "'onlyextensions' operator is not supported by change index version";
+ assertFailingQuery("onlyextensions:txt,jpg", unsupportedOperatorMessage);
+ assertFailingQuery("onlyexts:txt,jpg", unsupportedOperatorMessage);
+ return;
+ }
+
+ TestRepository<Repo> repo = createProject("repo");
+ Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+ Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+ Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+ Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+ Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
+ Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
+ Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
+
+ // case doesn't matter
+ assertQuery("onlyextensions:cc,h", change4, change2, change1);
+ assertQuery("onlyextensions:CC,H", change4, change2, change1);
+ assertQuery("onlyextensions:cc,H", change4, change2, change1);
+ assertQuery("onlyextensions:cC,h", change4, change2, change1);
+ assertQuery("onlyextensions:cc", change3);
+ assertQuery("onlyextensions:CC", change3);
+ assertQuery("onlyexts:java", change5);
+ assertQuery("onlyexts:jAvA", change5);
+ assertQuery("onlyexts:.jAvA", change5);
+
+ // order doesn't matter
+ assertQuery("onlyextensions:h,cc", change4, change2, change1);
+ assertQuery("onlyextensions:H,CC", change4, change2, change1);
+
+ // specifying extension with '.' is okay
+ assertQuery("onlyextensions:.cc,.h", change4, change2, change1);
+ assertQuery("onlyextensions:cc,.h", change4, change2, change1);
+ assertQuery("onlyextensions:.cc,h", change4, change2, change1);
+ assertQuery("onlyexts:.java", change5);
+
+ // matching changes without extension is possible
+ assertQuery("onlyexts:txt");
+ assertQuery("onlyexts:txt,", change6);
+ assertQuery("onlyexts:,txt", change6);
+ assertQuery("onlyextensions:\"\"", change7);
+ assertQuery("onlyexts:\"\"", change7);
+ assertQuery("onlyextensions:,", change7);
+ assertQuery("onlyexts:,", change7);
+ assertFailingQuery("onlyextensions:");
+ assertFailingQuery("onlyexts:");
+
+ // inverse queries
+ assertQuery("-onlyextensions:cc,h", change7, change6, change5, change3);
+ }
+
+ @Test
public void byComment() throws Exception {
TestRepository<Repo> repo = createProject("repo");
ChangeInserter ins = newChange(repo);
@@ -3126,12 +3182,19 @@
.isFalse();
}
- protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+ protected void assertFailingQuery(String query) throws Exception {
+ assertFailingQuery(query, null);
+ }
+
+ protected void assertFailingQuery(String query, @Nullable String expectedMessage)
+ throws Exception {
try {
assertQuery(query);
fail("expected BadRequestException for query '" + query + "'");
} catch (BadRequestException e) {
- assertThat(e.getMessage()).isEqualTo(expectedMessage);
+ if (expectedMessage != null) {
+ assertThat(e.getMessage()).isEqualTo(expectedMessage);
+ }
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 8aff87e..47bbaa1 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -66,6 +66,8 @@
'is:wip',
'label:',
'message:',
+ 'onlyexts:',
+ 'onlyextensions:',
'owner:',
'ownerin:',
'parentproject:',