Gitiles is a simple browser for Git repositories

It is based on JGit and uses Google Closure Templates as a templating
language. Access to underlying repositories is based on a few simple
interfaces; currently, there is only a simple disk-based
implementation, but other implementations are possible.

Features include viewing repositories by branch, shortlogs, showing
individual files and diffs with syntax highlighting, with many more
planned.

The application itself is a standard Java servlet and is configured
primarily via a git config format file. Deploying the WAR in any
servlet container should be possible.

In addition, a standalone server may be run with jetty-maven-plugin
with `mvn package && mvn jetty:run`.

Change-Id: I0ab8875b6c50f7df03b9a42b4a60923a4827bde7
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java
new file mode 100644
index 0000000..3e0acfa
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java
@@ -0,0 +1,214 @@
+// Copyright 2012 Google Inc. All Rights Reserved.
+//
+// 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.gitiles;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Objects;
+import com.google.common.base.Splitter;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+
+/** Object to parse revisions out of Gitiles paths. */
+class RevisionParser {
+  static final Splitter PATH_SPLITTER = Splitter.on('/');
+  private static final Splitter OPERATOR_SPLITTER = Splitter.on(CharMatcher.anyOf("^~"));
+
+  static class Result {
+    private final Revision revision;
+    private final Revision oldRevision;
+    private final int pathStart;
+
+    @VisibleForTesting
+    Result(Revision revision) {
+      this(revision, null, revision.getName().length());
+    }
+
+    @VisibleForTesting
+    Result(Revision revision, Revision oldRevision, int pathStart) {
+      this.revision = revision;
+      this.oldRevision = oldRevision;
+      this.pathStart = pathStart;
+    }
+
+    public Revision getRevision() {
+      return revision;
+    }
+
+    public Revision getOldRevision() {
+      return oldRevision;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Result) {
+        Result r = (Result) o;
+        return Objects.equal(revision, r.revision)
+            && Objects.equal(oldRevision, r.oldRevision)
+            && Objects.equal(pathStart, r.pathStart);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(revision, oldRevision, pathStart);
+    }
+
+    @Override
+    public String toString() {
+      return Objects.toStringHelper(this)
+          .omitNullValues()
+          .add("revision", revision)
+          .add("oldRevision", oldRevision)
+          .add("pathStart", pathStart)
+          .toString();
+    }
+
+    int getPathStart() {
+      return pathStart;
+    }
+  }
+
+  private final Repository repo;
+  private final GitilesAccess access;
+  private final VisibilityCache cache;
+
+  RevisionParser(Repository repo, GitilesAccess access, VisibilityCache cache) {
+    this.repo = checkNotNull(repo, "repo");
+    this.access = checkNotNull(access, "access");
+    this.cache = checkNotNull(cache, "cache");
+  }
+
+  Result parse(String path) throws IOException {
+    RevWalk walk = new RevWalk(repo);
+    try {
+      Revision oldRevision = null;
+
+      StringBuilder b = new StringBuilder();
+      boolean first = true;
+      for (String part : PATH_SPLITTER.split(path)) {
+        if (part.isEmpty()) {
+          return null; // No valid revision contains empty segments.
+        }
+        if (!first) {
+          b.append('/');
+        }
+
+        if (oldRevision == null) {
+          int dots = part.indexOf("..");
+          int firstParent = part.indexOf("^!");
+          if (dots == 0 || firstParent == 0) {
+            return null;
+          } else if (dots > 0) {
+            b.append(part.substring(0, dots));
+            String oldName = b.toString();
+            if (!isValidRevision(oldName)) {
+              return null;
+            } else {
+              ObjectId old = repo.resolve(oldName);
+              if (old == null) {
+                return null;
+              }
+              oldRevision = Revision.peel(oldName, old, walk);
+            }
+            part = part.substring(dots + 2);
+            b = new StringBuilder();
+          } else if (firstParent > 0) {
+            if (firstParent != part.length() - 2) {
+              return null;
+            }
+            b.append(part.substring(0, part.length() - 2));
+            String name = b.toString();
+            if (!isValidRevision(name)) {
+              return null;
+            }
+            ObjectId id = repo.resolve(name);
+            if (id == null) {
+              return null;
+            }
+            RevCommit c;
+            try {
+              c = walk.parseCommit(id);
+            } catch (IncorrectObjectTypeException e) {
+              return null; // Not a commit, ^! is invalid.
+            }
+            if (c.getParentCount() > 0) {
+              oldRevision = Revision.peeled(name + "^", c.getParent(0));
+            } else {
+              oldRevision = Revision.NULL;
+            }
+            Result result = new Result(Revision.peeled(name, c), oldRevision, name.length() + 2);
+            return isVisible(walk, result) ? result : null;
+          }
+        }
+        b.append(part);
+
+        String name = b.toString();
+        if (!isValidRevision(name)) {
+          return null;
+        }
+        ObjectId id = repo.resolve(name);
+        if (id != null) {
+          int pathStart;
+          if (oldRevision == null) {
+            pathStart = name.length(); // foo
+          } else {
+            // foo..bar (foo may be empty)
+            pathStart = oldRevision.getName().length() + 2 + name.length();
+          }
+          Result result = new Result(Revision.peel(name, id, walk), oldRevision, pathStart);
+          return isVisible(walk, result) ? result : null;
+        }
+        first = false;
+      }
+      return null;
+    } finally {
+      walk.release();
+    }
+  }
+
+  private static boolean isValidRevision(String revision) {
+    // Disallow some uncommon but valid revision expressions that either we
+    // don't support or we represent differently in our URLs.
+    return revision.indexOf(':') < 0
+        && revision.indexOf("^{") < 0
+        && revision.indexOf('@') < 0;
+  }
+
+  private boolean isVisible(RevWalk walk, Result result) throws IOException {
+    String maybeRef = OPERATOR_SPLITTER.split(result.getRevision().getName()).iterator().next();
+    if (repo.getRef(maybeRef) != null) {
+      // Name contains a visible ref; skip expensive reachability check.
+      return true;
+    }
+    if (!cache.isVisible(repo, walk, access, result.getRevision().getId())) {
+      return false;
+    }
+    if (result.getOldRevision() != null && result.getOldRevision() != Revision.NULL) {
+      return cache.isVisible(repo, walk, access, result.getOldRevision().getId());
+    } else {
+      return true;
+    }
+  }
+}