Merge "Support 'conflicts:<change>' operator to find changes with same files"
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 058be89..87ea1cd 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -74,6 +74,14 @@
 Either a legacy numerical 'ID' such as 15183, or a newer style
 Change-Id that was scraped out of the commit message.
 
+[[conflicts]]
+conflicts:'ID'::
++
+Changes that potentially conflict with change 'ID' because they touch
+at least one file that was also touched by change 'ID'. Change 'ID' can
+be specified as a legacy numerical 'ID' such as 15183, or a newer style
+Change-Id that was scraped out of the commit message.
+
 [[owner]]
 owner:'USER'::
 +
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 784b461..b24ec47 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -55,10 +55,14 @@
   }
 
   private void beforeTest(Config cfg, boolean memory) throws Exception {
-    server = GerritServer.start(cfg, memory);
+    server = startServer(cfg, memory);
     server.getTestInjector().injectMembers(this);
   }
 
+  protected GerritServer startServer(Config cfg, boolean memory) throws Exception {
+    return GerritServer.start(cfg, memory);
+  }
+
   private void afterTest() throws Exception {
     server.stop();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTestWithSecondaryIndex.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTestWithSecondaryIndex.java
new file mode 100644
index 0000000..7ab5911
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTestWithSecondaryIndex.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2013 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;
+
+import org.eclipse.jgit.lib.Config;
+
+public class AbstractDaemonTestWithSecondaryIndex extends AbstractDaemonTest {
+
+  @Override
+  protected GerritServer startServer(Config cfg, boolean memory)
+      throws Exception {
+    return GerritServer.start(cfg, memory, true);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/GerritServer.java
index daf9d38..6fec519 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/GerritServer.java
@@ -15,10 +15,13 @@
 package com.google.gerrit.acceptance;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.Daemon;
 import com.google.gerrit.pgm.Init;
+import com.google.gerrit.pgm.Reindex;
 import com.google.gerrit.server.config.FactoryModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.ChangeSchemas;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -48,6 +51,12 @@
 
   /** Returns fully started Gerrit server */
   static GerritServer start(Config base, boolean memory) throws Exception {
+    return start(base, memory, false);
+  }
+
+  /** Returns fully started Gerrit server */
+  static GerritServer start(Config base, boolean memory, boolean index)
+      throws Exception {
     final CyclicBarrier serverStarted = new CyclicBarrier(2);
     final Daemon daemon = new Daemon(new Runnable() {
       public void run() {
@@ -69,11 +78,25 @@
       mergeTestConfig(cfg);
       cfg.setBoolean("httpd", null, "requestLog", false);
       cfg.setBoolean("sshd", null, "requestLog", false);
+      if (index) {
+        cfg.setString("index", null, "type", "lucene");
+        cfg.setBoolean("index", "lucene", "testInmemory", true);
+        daemon.setLuceneModule(new LuceneIndexModule(
+            ChangeSchemas.getLatest().getVersion(),
+            Runtime.getRuntime().availableProcessors(), null));
+      }
       daemon.setDatabaseForTesting(ImmutableList.<Module>of(
           new InMemoryTestingDatabaseModule(cfg)));
       daemon.start();
     } else {
-      site = initSite(base);
+      Config cfg = base != null ? base : new Config();
+      if (index) {
+        cfg.setString("index", null, "type", "lucene");
+      }
+      site = initSite(cfg);
+      if (index) {
+        reindex(site);
+      }
       daemonService = Executors.newSingleThreadExecutor();
       daemonService.submit(new Callable<Void>() {
         public Void call() throws Exception {
@@ -114,6 +137,15 @@
     return tmp;
   }
 
+  /** Runs the reindex command. Works only if the site is not currently running. */
+  private static void reindex(File site) throws Exception {
+    Reindex reindex = new Reindex();
+    int rc = reindex.main(new String[] {"-d", site.getPath()});
+    if (rc != 0) {
+      throw new RuntimeException("Reindex failed");
+    }
+  }
+
   private static void mergeTestConfig(Config cfg)
       throws IOException {
     InetSocketAddress http = newPort();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConflictsOperatorIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConflictsOperatorIT.java
new file mode 100644
index 0000000..1472f38
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConflictsOperatorIT.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2013 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.rest.change;
+
+import static com.google.gerrit.acceptance.git.GitUtil.checkout;
+import static com.google.gerrit.acceptance.git.GitUtil.cloneProject;
+import static com.google.gerrit.acceptance.git.GitUtil.initSsh;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTestWithSecondaryIndex;
+import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.SshSession;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.git.GitUtil;
+import com.google.gerrit.acceptance.git.PushOneCommit;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+
+import com.jcraft.jsch.JSchException;
+
+import org.apache.http.HttpStatus;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Set;
+
+public class ConflictsOperatorIT extends AbstractDaemonTestWithSecondaryIndex {
+
+  @Inject
+  private AccountCreator accounts;
+
+  @Inject
+  private SchemaFactory<ReviewDb> reviewDbProvider;
+
+  private TestAccount admin;
+  private RestSession session;
+  private Project.NameKey project;
+  private ReviewDb db;
+  private int count;
+
+  @Before
+  public void setUp() throws Exception {
+    admin = accounts.admin();
+    session = new RestSession(server, admin);
+    initSsh(admin);
+
+    project = new Project.NameKey("p");
+
+    db = reviewDbProvider.open();
+  }
+
+  @Test
+  public void noConflictingChanges() throws JSchException, IOException,
+      GitAPIException {
+    Git git = createProject();
+    PushOneCommit.Result change = createChange(git, true);
+    createChange(git, false);
+
+    Set<String> changes = queryConflictingChanges(change);
+    assertEquals(0, changes.size());
+  }
+
+  @Test
+  public void conflictingChanges() throws JSchException, IOException,
+      GitAPIException {
+    Git git = createProject();
+    PushOneCommit.Result change = createChange(git, true);
+    PushOneCommit.Result conflictingChange1 = createChange(git, true);
+    PushOneCommit.Result conflictingChange2 = createChange(git, true);
+    createChange(git, false);
+
+    Set<String> changes = queryConflictingChanges(change);
+    assertChanges(changes, conflictingChange1, conflictingChange2);
+  }
+
+  private Git createProject() throws JSchException, IOException,
+      GitAPIException {
+    SshSession sshSession = new SshSession(server, admin);
+    try {
+      GitUtil.createProject(sshSession, project.get(), null, true);
+      return cloneProject(sshSession.getUrl() + "/" + project.get());
+    } finally {
+      sshSession.close();
+    }
+  }
+
+  private PushOneCommit.Result createChange(Git git, boolean conflicting)
+      throws GitAPIException, IOException {
+    checkout(git, "origin/master");
+    String file = conflicting ? "test.txt" : "test-" + count + ".txt";
+    PushOneCommit push =
+        new PushOneCommit(db, admin.getIdent(), "Change " + count, file,
+            "content " + count);
+    count++;
+    return push.to(git, "refs/for/master");
+  }
+
+  private Set<String> queryConflictingChanges(PushOneCommit.Result change)
+      throws IOException {
+    RestResponse r =
+        session.get("/changes/?q=conflicts:" + change.getChangeId());
+    assertEquals(HttpStatus.SC_OK, r.getStatusCode());
+    Set<ChangeInfo> changes =
+        (new Gson()).fromJson(r.getReader(),
+            new TypeToken<Set<ChangeInfo>>() {}.getType());
+    r.consume();
+    return ImmutableSet.copyOf(Iterables.transform(changes,
+        new Function<ChangeInfo, String>() {
+          @Override
+          public String apply(ChangeInfo input) {
+            return input.id;
+          }
+        }));
+  }
+
+  private void assertChanges(Set<String> actualChanges,
+      PushOneCommit.Result... expectedChanges) {
+    assertEquals(expectedChanges.length, actualChanges.size());
+    for (PushOneCommit.Result c : expectedChanges) {
+      assertTrue(actualChanges.contains(id(c)));
+    }
+  }
+
+  private String id(PushOneCommit.Result change) {
+    return project.get() + "~master~" + change.getChangeId();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
index da9f1c6..a526c59 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
@@ -80,6 +80,7 @@
 
     suggestions.add("commit:");
     suggestions.add("comment:");
+    suggestions.add("conflicts:");
     suggestions.add("project:");
     suggestions.add("branch:");
     suggestions.add("topic:");
@@ -89,7 +90,6 @@
     suggestions.add("label:");
     suggestions.add("message:");
     suggestions.add("file:");
-
     suggestions.add("has:");
     suggestions.add("has:draft");
     suggestions.add("has:star");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 35d8e76..9c28135 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -138,6 +138,7 @@
   private Injector httpdInjector;
   private File runFile;
   private boolean test;
+  private AbstractModule luceneModule;
 
   private Runnable serverStarted;
 
@@ -250,6 +251,12 @@
   }
 
   @VisibleForTesting
+  public void setLuceneModule(LuceneIndexModule m) {
+    luceneModule = m;
+    test = true;
+  }
+
+  @VisibleForTesting
   public void start() {
     if (dbInjector == null) {
       dbInjector = createDbInjector(MULTI_USER);
@@ -303,7 +310,7 @@
     AbstractModule changeIndexModule;
     switch (IndexModule.getIndexType(cfgInjector)) {
       case LUCENE:
-        changeIndexModule = new LuceneIndexModule();
+        changeIndexModule = luceneModule != null ? luceneModule : new LuceneIndexModule();
         break;
       case SOLR:
         changeIndexModule = new SolrIndexModule();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 9fc3dbf..85459b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -84,6 +84,7 @@
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_COMMENT = "comment";
   public static final String FIELD_COMMIT = "commit";
+  public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DRAFTBY = "draftby";
   public static final String FIELD_FILE = "file";
   public static final String FIELD_IS = "is";
@@ -218,10 +219,7 @@
           .parse(query));
 
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
-      if (query.charAt(0) == 'i') {
-        query = "I" + query.substring(1);
-      }
-      return new ChangeIdPredicate(args.dbProvider, query);
+      return new ChangeIdPredicate(args.dbProvider, parseChangeId(query));
     }
 
     throw new IllegalArgumentException();
@@ -308,6 +306,14 @@
   }
 
   @Operator
+  public Predicate<ChangeData> conflicts(String value) throws OrmException,
+      QueryParseException {
+    requireIndex(FIELD_CONFLICTS, value);
+    return new ConflictsPredicate(args.dbProvider, args.patchListCache, value,
+        parseChange(value));
+  }
+
+  @Operator
   public Predicate<ChangeData> project(String name) {
     if (name.startsWith("^"))
       return new RegexProjectPredicate(args.dbProvider, name);
@@ -665,6 +671,31 @@
     return g;
   }
 
+  private List<Change> parseChange(String value) throws OrmException,
+      QueryParseException {
+    if (PAT_LEGACY_ID.matcher(value).matches()) {
+      return Collections.singletonList(args.dbProvider.get().changes()
+          .get(Change.Id.parse(value)));
+    } else if (PAT_CHANGE_ID.matcher(value).matches()) {
+      Change.Key a = new Change.Key(parseChangeId(value));
+      List<Change> changes =
+          args.dbProvider.get().changes().byKeyRange(a, a.max()).toList();
+      if (changes.isEmpty()) {
+        throw error("Change " + value + " not found");
+      }
+      return changes;
+    }
+
+    throw error("Change " + value + " not found");
+  }
+
+  private static String parseChangeId(String value) {
+    if (value.charAt(0) == 'i') {
+      value = "I" + value.substring(1);
+    }
+    return value;
+  }
+
   private Account.Id self() {
     if (currentUser.isIdentifiedUser()) {
       return ((IdentifiedUser) currentUser).getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
new file mode 100644
index 0000000..9cb2d94
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2013 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 com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+
+import java.util.List;
+
+class ConflictsPredicate extends OrPredicate<ChangeData> {
+  private final String value;
+
+  ConflictsPredicate(Provider<ReviewDb> db, PatchListCache plc, String value,
+      List<Change> changes) throws OrmException {
+    super(predicates(db, plc, changes));
+    this.value = value;
+  }
+
+  private static List<Predicate<ChangeData>> predicates(Provider<ReviewDb> db,
+      PatchListCache plc, List<Change> changes) throws OrmException {
+    List<Predicate<ChangeData>> changePredicates =
+        Lists.newArrayListWithCapacity(changes.size());
+    for (Change c : changes) {
+      List<String> files = new ChangeData(c).currentFilePaths(db, plc);
+      List<Predicate<ChangeData>> filePredicates =
+          Lists.newArrayListWithCapacity(files.size());
+      for (String file : files) {
+        filePredicates.add(new EqualsFilePredicate(db, plc, file));
+      }
+
+      List<Predicate<ChangeData>> predicatesForOneChange =
+          Lists.newArrayListWithCapacity(2);
+      predicatesForOneChange.add(
+          not(new LegacyChangeIdPredicate(db, c.getId())));
+      predicatesForOneChange.add(or(filePredicates));
+
+      changePredicates.add(and(predicatesForOneChange));
+    }
+    return changePredicates;
+  }
+
+  @Override
+  public String toString() {
+    return ChangeQueryBuilder.FIELD_CONFLICTS + ":" + value;
+  }
+}