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/.gitignore b/.gitignore
new file mode 100644
index 0000000..ebbbd5f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/.classpath
+/.project
+/.settings
diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs
new file mode 100644
index 0000000..5b781ec
--- /dev/null
+++ b/.settings/org.eclipse.core.resources.prefs
@@ -0,0 +1,3 @@
+eclipse.preferences.version=1
+encoding//src/main/java=UTF-8
+encoding//src/test/java=UTF-8
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 0000000..60105c1
--- /dev/null
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,5 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6
+org.eclipse.jdt.core.compiler.compliance=1.6
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.source=1.6
diff --git a/.settings/org.eclipse.jdt.ui.prefs b/.settings/org.eclipse.jdt.ui.prefs
new file mode 100644
index 0000000..fa9df30
--- /dev/null
+++ b/.settings/org.eclipse.jdt.ui.prefs
@@ -0,0 +1,5 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.ui.ignorelowercasenames=true
+org.eclipse.jdt.ui.importorder=com.google;com;junit;net;org;java;javax;
+org.eclipse.jdt.ui.ondemandthreshold=999
+org.eclipse.jdt.ui.staticondemandthreshold=999
diff --git a/gitiles-servlet/pom.xml b/gitiles-servlet/pom.xml
new file mode 100644
index 0000000..5537693
--- /dev/null
+++ b/gitiles-servlet/pom.xml
@@ -0,0 +1,115 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>com.google.gitiles</groupId>
+ <artifactId>gitiles-parent</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>gitiles-servlet</artifactId>
+ <name>Gitiles - Servlet</name>
+
+ <description>
+ Gitiles servlet implementation
+ </description>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>com.google.template</groupId>
+ <artifactId>soy</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit.http.server</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit.junit</artifactId>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.tomcat</groupId>
+ <artifactId>servlet-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>joda-time</groupId>
+ <artifactId>joda-time</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-lang3</artifactId>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <executions>
+ <execution>
+ <goals>
+ <goal>jar</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java
new file mode 100644
index 0000000..092e12d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java
@@ -0,0 +1,49 @@
+// 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 java.io.IOException;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+abstract class AbstractHttpFilter implements Filter {
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
+ throws IOException, ServletException {
+ doFilter((HttpServletRequest) req, (HttpServletResponse) res, chain);
+ }
+
+ @Override
+ @SuppressWarnings("unused") // Allow subclasses to throw ServletException.
+ public void init(FilterConfig config) throws ServletException {
+ // Default implementation does nothing.
+ }
+
+ @Override
+ public void destroy() {
+ // Default implementation does nothing.
+ }
+
+ /** @see #doFilter(ServletRequest, ServletResponse, FilterChain) */
+ public abstract void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+ throws IOException, ServletException;
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
new file mode 100644
index 0000000..70dc14e
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
@@ -0,0 +1,119 @@
+// 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 javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.common.net.HttpHeaders;
+
+import org.joda.time.Instant;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Base servlet class for Gitiles servlets that serve Soy templates. */
+public abstract class BaseServlet extends HttpServlet {
+ private static final String DATA_ATTRIBUTE = BaseServlet.class.getName() + "/Data";
+
+ static void setNotCacheable(HttpServletResponse res) {
+ res.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0, must-revalidate");
+ res.setHeader(HttpHeaders.PRAGMA, "no-cache");
+ res.setHeader(HttpHeaders.EXPIRES, "Fri, 01 Jan 1990 00:00:00 GMT");
+ res.setDateHeader(HttpHeaders.DATE, new Instant().getMillis());
+ }
+
+ public static BaseServlet notFoundServlet() {
+ return new BaseServlet(null) {
+ @Override
+ public void service(HttpServletRequest req, HttpServletResponse res) {
+ res.setStatus(SC_NOT_FOUND);
+ }
+ };
+ }
+
+ public static Map<String, String> menuEntry(String text, String url) {
+ if (url != null) {
+ return ImmutableMap.of("text", text, "url", url);
+ } else {
+ return ImmutableMap.of("text", text);
+ }
+ }
+
+ protected static Map<String, Object> getData(HttpServletRequest req) {
+ @SuppressWarnings("unchecked")
+ Map<String, Object> data = (Map<String, Object>) req.getAttribute(DATA_ATTRIBUTE);
+ if (data == null) {
+ data = Maps.newHashMap();
+ req.setAttribute(DATA_ATTRIBUTE, data);
+ }
+ return data;
+ }
+
+ protected final Renderer renderer;
+
+ protected BaseServlet(Renderer renderer) {
+ this.renderer = renderer;
+ }
+
+ /**
+ * Put a value into a request's Soy data map.
+ * <p>
+ * This method is intended to support a composition pattern whereby a
+ * {@link BaseServlet} is wrapped in a different {@link HttpServlet} that can
+ * update its data map.
+ *
+ * @param req in-progress request.
+ * @param key key.
+ * @param value Soy data value.
+ */
+ public void put(HttpServletRequest req, String key, Object value) {
+ getData(req).put(key, value);
+ }
+
+ protected void render(HttpServletRequest req, HttpServletResponse res, String templateName,
+ Map<String, ?> soyData) throws IOException {
+ try {
+ res.setContentType(FormatType.HTML.getMimeType());
+ res.setCharacterEncoding(Charsets.UTF_8.name());
+ setCacheHeaders(req, res);
+
+ Map<String, Object> allData = getData(req);
+ allData.putAll(soyData);
+ GitilesView view = ViewFilter.getView(req);
+ if (!allData.containsKey("repositoryName") && view.getRepositoryName() != null) {
+ allData.put("repositoryName", view.getRepositoryName());
+ }
+ if (!allData.containsKey("breadcrumbs")) {
+ allData.put("breadcrumbs", view.getBreadcrumbs());
+ }
+
+ res.setStatus(HttpServletResponse.SC_OK);
+ renderer.render(res, templateName, allData);
+ } finally {
+ req.removeAttribute(DATA_ATTRIBUTE);
+ }
+ }
+
+ protected void setCacheHeaders(HttpServletRequest req, HttpServletResponse res) {
+ setNotCacheable(res);
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
new file mode 100644
index 0000000..6b57cbc
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
@@ -0,0 +1,109 @@
+// 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 org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.IOException;
+import java.util.Map;
+
+/** Soy data converter for git blobs. */
+public class BlobSoyData {
+ /**
+ * Maximum number of bytes to load from a supposed text file for display.
+ * Files larger than this will be displayed as binary files, even if the
+ * contents was text. For example really big XML files may be above this limit
+ * and will get displayed as binary.
+ */
+ private static final int MAX_FILE_SIZE = 10 << 20;
+
+ private final GitilesView view;
+ private final RevWalk walk;
+
+ public BlobSoyData(RevWalk walk, GitilesView view) {
+ this.view = view;
+ this.walk = walk;
+ }
+
+ public Map<String, Object> toSoyData(ObjectId blobId)
+ throws MissingObjectException, IOException {
+ return toSoyData(null, blobId);
+ }
+
+ public Map<String, Object> toSoyData(String path, ObjectId blobId)
+ throws MissingObjectException, IOException {
+ Map<String, Object> data = Maps.newHashMapWithExpectedSize(4);
+ data.put("sha", ObjectId.toString(blobId));
+
+ ObjectLoader loader = walk.getObjectReader().open(blobId, Constants.OBJ_BLOB);
+ String content;
+ try {
+ byte[] raw = loader.getCachedBytes(MAX_FILE_SIZE);
+ content = !RawText.isBinary(raw) ? RawParseUtils.decode(raw) : null;
+ } catch (LargeObjectException.OutOfMemory e) {
+ throw e;
+ } catch (LargeObjectException e) {
+ content = null;
+ }
+
+ data.put("data", content);
+ if (content != null) {
+ data.put("lang", guessPrettifyLang(path, content));
+ } else if (content == null) {
+ data.put("size", Long.toString(loader.getSize()));
+ }
+ if (path != null && view.getRevision().getPeeledType() == OBJ_COMMIT) {
+ data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
+ }
+ return data;
+ }
+
+ private static String guessPrettifyLang(String path, String content) {
+ if (content.startsWith("#!/bin/sh") || content.startsWith("#!/bin/bash")) {
+ return "sh";
+ } else if (content.startsWith("#!/usr/bin/perl")) {
+ return "perl";
+ } else if (content.startsWith("#!/usr/bin/python")) {
+ return "py";
+ } else if (path == null) {
+ return null;
+ }
+
+ int slash = path.lastIndexOf('/');
+ int dot = path.lastIndexOf('.');
+ String lang = ((0 < dot) && (slash < dot)) ? path.substring(dot + 1) : null;
+ if ("txt".equalsIgnoreCase(lang)) {
+ return null;
+ } else if ("mk".equalsIgnoreCase(lang)) {
+ return "sh";
+ } else if ("Makefile".equalsIgnoreCase(path)
+ || ((0 < slash) && "Makefile".equalsIgnoreCase(path.substring(slash + 1)))) {
+ return "sh";
+ } else {
+ return lang;
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java
new file mode 100644
index 0000000..9f8a97d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java
@@ -0,0 +1,287 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.template.soy.data.restricted.NullData;
+
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.AbstractTreeIterator;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+import org.eclipse.jgit.treewalk.EmptyTreeIterator;
+import org.eclipse.jgit.util.GitDateFormatter;
+import org.eclipse.jgit.util.GitDateFormatter.Format;
+import org.eclipse.jgit.util.RelativeDateFormatter;
+import org.eclipse.jgit.util.io.NullOutputStream;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+import javax.servlet.http.HttpServletRequest;
+
+/** Soy data converter for git commits. */
+public class CommitSoyData {
+ /** Valid sets of keys to include in Soy data for commits. */
+ public static enum KeySet {
+ DETAIL("author", "committer", "sha", "tree", "treeUrl", "parents", "message", "logUrl"),
+ DETAIL_DIFF_TREE(DETAIL, "diffTree"),
+ SHORTLOG("abbrevSha", "url", "shortMessage", "author", "branches", "tags"),
+ DEFAULT(DETAIL);
+
+ private final Set<String> keys;
+
+ private KeySet(String... keys) {
+ this.keys = ImmutableSet.copyOf(keys);
+ }
+
+ private KeySet(KeySet other, String... keys) {
+ this.keys = ImmutableSet.<String> builder().addAll(other.keys).add(keys).build();
+ }
+ }
+
+ private final Linkifier linkifier;
+ private final HttpServletRequest req;
+ private final Repository repo;
+ private final RevWalk walk;
+ private final GitilesView view;
+ private final Map<AnyObjectId, Set<Ref>> refsById;
+ private final GitDateFormatter dateFormatter;
+
+ // TODO(dborowitz): This constructor is getting a bit ridiculous.
+ public CommitSoyData(@Nullable Linkifier linkifier, HttpServletRequest req, Repository repo,
+ RevWalk walk, GitilesView view) {
+ this(linkifier, req, repo, walk, view, null);
+ }
+
+ public CommitSoyData(@Nullable Linkifier linkifier, HttpServletRequest req, Repository repo,
+ RevWalk walk, GitilesView view, @Nullable Map<AnyObjectId, Set<Ref>> refsById) {
+ this.linkifier = linkifier;
+ this.req = req;
+ this.repo = repo;
+ this.walk = walk;
+ this.view = view;
+ this.refsById = refsById;
+ this.dateFormatter = new GitDateFormatter(Format.DEFAULT);
+ }
+
+ public Map<String, Object> toSoyData(RevCommit commit, KeySet keys) throws IOException {
+ Map<String, Object> data = Maps.newHashMapWithExpectedSize(KeySet.DEFAULT.keys.size());
+ if (keys.keys.contains("author")) {
+ data.put("author", toSoyData(commit.getAuthorIdent(), dateFormatter));
+ }
+ if (keys.keys.contains("committer")) {
+ data.put("committer", toSoyData(commit.getCommitterIdent(), dateFormatter));
+ }
+ if (keys.keys.contains("sha")) {
+ data.put("sha", ObjectId.toString(commit));
+ }
+ if (keys.keys.contains("abbrevSha")) {
+ ObjectReader reader = repo.getObjectDatabase().newReader();
+ try {
+ data.put("abbrevSha", reader.abbreviate(commit).name());
+ } finally {
+ reader.release();
+ }
+ }
+ if (keys.keys.contains("url")) {
+ data.put("url", GitilesView.revision()
+ .copyFrom(view)
+ .setRevision(commit)
+ .toUrl());
+ }
+ if (keys.keys.contains("logUrl")) {
+ Revision rev = view.getRevision();
+ GitilesView.Builder logView = GitilesView.log()
+ .copyFrom(view)
+ .setRevision(rev.getId().equals(commit) ? rev.getName() : commit.name(), commit)
+ .setOldRevision(Revision.NULL)
+ .setTreePath(null);
+ data.put("logUrl", logView.toUrl());
+ }
+ if (keys.keys.contains("tree")) {
+ data.put("tree", ObjectId.toString(commit.getTree()));
+ }
+ if (keys.keys.contains("treeUrl")) {
+ data.put("treeUrl", GitilesView.path().copyFrom(view).setTreePath("/").toUrl());
+ }
+ if (keys.keys.contains("parents")) {
+ data.put("parents", toSoyData(view, commit.getParents()));
+ }
+ if (keys.keys.contains("shortMessage")) {
+ data.put("shortMessage", commit.getShortMessage());
+ }
+ if (keys.keys.contains("branches")) {
+ data.put("branches", getRefsById(commit, Constants.R_HEADS));
+ }
+ if (keys.keys.contains("tags")) {
+ data.put("tags", getRefsById(commit, Constants.R_TAGS));
+ }
+ if (keys.keys.contains("message")) {
+ if (linkifier != null) {
+ data.put("message", linkifier.linkify(req, commit.getFullMessage()));
+ } else {
+ data.put("message", commit.getFullMessage());
+ }
+ }
+ if (keys.keys.contains("diffTree")) {
+ data.put("diffTree", computeDiffTree(commit));
+ }
+ checkState(keys.keys.size() == data.size(), "bad commit data keys: %s != %s", keys.keys,
+ data.keySet());
+ return ImmutableMap.copyOf(data);
+ }
+
+ public Map<String, Object> toSoyData(RevCommit commit) throws IOException {
+ return toSoyData(commit, KeySet.DEFAULT);
+ }
+
+ // TODO(dborowitz): Extract this.
+ static Map<String, String> toSoyData(PersonIdent ident, GitDateFormatter dateFormatter) {
+ return ImmutableMap.of(
+ "name", ident.getName(),
+ "email", ident.getEmailAddress(),
+ "time", dateFormatter.formatDate(ident),
+ // TODO(dborowitz): Switch from relative to absolute at some threshold.
+ "relativeTime", RelativeDateFormatter.format(ident.getWhen()));
+ }
+
+ private List<Map<String, String>> toSoyData(GitilesView view, RevCommit[] parents) {
+ List<Map<String, String>> result = Lists.newArrayListWithCapacity(parents.length);
+ int i = 1;
+ // TODO(dborowitz): Render something slightly different when we're actively
+ // viewing a diff against one of the parents.
+ for (RevCommit parent : parents) {
+ String name = parent.name();
+ GitilesView.Builder diff = GitilesView.diff().copyFrom(view).setTreePath("");
+ String parentName;
+ if (parents.length == 1) {
+ parentName = view.getRevision().getName() + "^";
+ } else {
+ parentName = view.getRevision().getName() + "^" + (i++);
+ }
+ result.add(ImmutableMap.of(
+ "sha", name,
+ "url", GitilesView.revision()
+ .copyFrom(view)
+ .setRevision(parentName, parent)
+ .toUrl(),
+ "diffUrl", diff.setOldRevision(parentName, parent).toUrl()));
+ }
+ return result;
+ }
+
+ private AbstractTreeIterator getTreeIterator(RevWalk walk, RevCommit commit) throws IOException {
+ CanonicalTreeParser p = new CanonicalTreeParser();
+ p.reset(walk.getObjectReader(), walk.parseTree(walk.parseCommit(commit).getTree()));
+ return p;
+ }
+
+ private Object computeDiffTree(RevCommit commit) throws IOException {
+ AbstractTreeIterator oldTree;
+ GitilesView.Builder diffUrl = GitilesView.diff().copyFrom(view)
+ .setTreePath("");
+ switch (commit.getParentCount()) {
+ case 0:
+ oldTree = new EmptyTreeIterator();
+ diffUrl.setOldRevision(Revision.NULL);
+ break;
+ case 1:
+ oldTree = getTreeIterator(walk, commit.getParent(0));
+ diffUrl.setOldRevision(view.getRevision().getName() + "^", commit.getParent(0));
+ break;
+ default:
+ // TODO(dborowitz): handle merges
+ return NullData.INSTANCE;
+ }
+ AbstractTreeIterator newTree = getTreeIterator(walk, commit);
+
+ DiffFormatter diff = new DiffFormatter(NullOutputStream.INSTANCE);
+ try {
+ diff.setRepository(repo);
+ diff.setDetectRenames(true);
+
+ List<Object> result = Lists.newArrayList();
+ for (DiffEntry e : diff.scan(oldTree, newTree)) {
+ Map<String, Object> entry = Maps.newHashMapWithExpectedSize(5);
+ entry.put("path", e.getNewPath());
+ entry.put("url", GitilesView.path()
+ .copyFrom(view)
+ .setTreePath(e.getNewPath())
+ .toUrl());
+ entry.put("diffUrl", diffUrl.setAnchor("F" + result.size()).toUrl());
+ entry.put("changeType", e.getChangeType().toString());
+ if (e.getChangeType() == ChangeType.COPY || e.getChangeType() == ChangeType.RENAME) {
+ entry.put("oldPath", e.getOldPath());
+ }
+ result.add(entry);
+ }
+ return result;
+ } finally {
+ diff.release();
+ }
+ }
+
+ private static final Comparator<Map<String, String>> NAME_COMPARATOR =
+ new Comparator<Map<String, String>>() {
+ @Override
+ public int compare(Map<String, String> o1, Map<String, String> o2) {
+ return o1.get("name").compareTo(o2.get("name"));
+ }
+ };
+
+ private List<Map<String, String>> getRefsById(ObjectId id, String prefix) {
+ checkNotNull(refsById, "must pass in ID to ref map to look up refs by ID");
+ Set<Ref> refs = refsById.get(id);
+ if (refs == null) {
+ return ImmutableList.of();
+ }
+ List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size());
+ for (Ref ref : refs) {
+ if (ref.getName().startsWith(prefix)) {
+ result.add(ImmutableMap.of(
+ "name", ref.getName().substring(prefix.length()),
+ "url", GitilesView.revision()
+ .copyFrom(view)
+ .setRevision(Revision.unpeeled(ref.getName(), ref.getObjectId()))
+ .toUrl()));
+ }
+ }
+ Collections.sort(result, NAME_COMPARATOR);
+ return result;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java b/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java
new file mode 100644
index 0000000..4f8733b
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java
@@ -0,0 +1,141 @@
+// 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 com.google.common.base.Predicates;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+
+import org.eclipse.jgit.lib.Config;
+import org.joda.time.Duration;
+
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Utilities for working with {@link Config} objects. */
+public class ConfigUtil {
+ /**
+ * Read a duration value from the configuration.
+ * <p>
+ * Durations can be written as expressions, for example {@code "1 s"} or
+ * {@code "5 days"}. If units are not specified, milliseconds are assumed.
+ *
+ * @param config JGit config object.
+ * @param section section to read, e.g. "google"
+ * @param subsection subsection to read, e.g. "bigtable"
+ * @param name variable to read, e.g. "deadline".
+ * @param defaultValue value to use when the value is not assigned.
+ * @return a standard duration representing the time read, or defaultValue.
+ */
+ public static Duration getDuration(Config config, String section, String subsection, String name,
+ Duration defaultValue) {
+ String valStr = config.getString(section, subsection, name);
+ if (valStr == null) {
+ return defaultValue;
+ }
+
+ valStr = valStr.trim();
+ if (valStr.length() == 0) {
+ return defaultValue;
+ }
+
+ Matcher m = matcher("^([1-9][0-9]*(?:\\.[0-9]*)?)\\s*(.*)$", valStr);
+ if (!m.matches()) {
+ String key = section + (subsection != null ? "." + subsection : "") + "." + name;
+ throw new IllegalStateException("Not time unit: " + key + " = " + valStr);
+ }
+
+ String digits = m.group(1);
+ String unitName = m.group(2).trim();
+
+ TimeUnit unit;
+ if ("".equals(unitName)) {
+ unit = TimeUnit.MILLISECONDS;
+ } else if (anyOf(unitName, "ms", "millis", "millisecond", "milliseconds")) {
+ unit = TimeUnit.MILLISECONDS;
+ } else if (anyOf(unitName, "s", "sec", "second", "seconds")) {
+ unit = TimeUnit.SECONDS;
+ } else if (anyOf(unitName, "m", "min", "minute", "minutes")) {
+ unit = TimeUnit.MINUTES;
+ } else if (anyOf(unitName, "h", "hr", "hour", "hours")) {
+ unit = TimeUnit.HOURS;
+ } else if (anyOf(unitName, "d", "day", "days")) {
+ unit = TimeUnit.DAYS;
+ } else {
+ String key = section + (subsection != null ? "." + subsection : "") + "." + name;
+ throw new IllegalStateException("Not time unit: " + key + " = " + valStr);
+ }
+
+ try {
+ if (digits.indexOf('.') == -1) {
+ long val = Long.parseLong(digits);
+ return new Duration(val * TimeUnit.MILLISECONDS.convert(1, unit));
+ } else {
+ double val = Double.parseDouble(digits);
+ return new Duration((long) (val * TimeUnit.MILLISECONDS.convert(1, unit)));
+ }
+ } catch (NumberFormatException nfe) {
+ String key = section + (subsection != null ? "." + subsection : "") + "." + name;
+ throw new IllegalStateException("Not time unit: " + key + " = " + valStr, nfe);
+ }
+ }
+
+ /**
+ * Get a {@link CacheBuilder} from a config.
+ *
+ * @param config JGit config object.
+ * @param name name of the cache subsection under the "cache" section.
+ * @return a new cache builder.
+ */
+ public static CacheBuilder<Object, Object> getCacheBuilder(Config config, String name) {
+ CacheBuilder<Object, Object> b = CacheBuilder.newBuilder();
+ try {
+ if (config.getString("cache", name, "maximumWeight") != null) {
+ b.maximumWeight(config.getLong("cache", name, "maximumWeight", 20 << 20));
+ }
+ if (config.getString("cache", name, "maximumSize") != null) {
+ b.maximumSize(config.getLong("cache", name, "maximumSize", 16384));
+ }
+ Duration expireAfterWrite = getDuration(config, "cache", name, "expireAfterWrite", null);
+ if (expireAfterWrite != null) {
+ b.expireAfterWrite(expireAfterWrite.getMillis(), TimeUnit.MILLISECONDS);
+ }
+ Duration expireAfterAccess = getDuration(config, "cache", name, "expireAfterAccess", null);
+ if (expireAfterAccess != null) {
+ b.expireAfterAccess(expireAfterAccess.getMillis(), TimeUnit.MILLISECONDS);
+ }
+ // Add other methods as needed.
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Error getting CacheBuilder for " + name, e);
+ } catch (IllegalStateException e) {
+ throw new IllegalStateException("Error getting CacheBuilder for " + name, e);
+ }
+ return b;
+ }
+
+ private static Matcher matcher(String pattern, String valStr) {
+ return Pattern.compile(pattern).matcher(valStr);
+ }
+
+ private static boolean anyOf(String a, String... cases) {
+ return Iterables.any(ImmutableList.copyOf(cases),
+ Predicates.equalTo(a.toLowerCase()));
+ }
+
+ private ConfigUtil() {
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java
new file mode 100644
index 0000000..e56518a
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java
@@ -0,0 +1,57 @@
+// 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.checkState;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.tofu.SoyTofu;
+
+import java.io.File;
+import java.net.URISyntaxException;
+import java.net.URL;
+
+/** Renderer that reloads Soy templates from the filesystem on every request. */
+public class DebugRenderer extends Renderer {
+ public DebugRenderer(String staticPrefix, String customTemplatesFilename,
+ final String soyTemplatesRoot) {
+ super(
+ new Function<String, URL>() {
+ @Override
+ public URL apply(String name) {
+ return toFileURL(soyTemplatesRoot + File.separator + name);
+ }
+ },
+ ImmutableMap.<String, String> of(), staticPrefix,
+ toFileURL(customTemplatesFilename));
+ }
+
+ @Override
+ protected SoyTofu getTofu() {
+ SoyFileSet.Builder builder = new SoyFileSet.Builder()
+ .setCompileTimeGlobals(globals);
+ for (URL template : templates) {
+ try {
+ checkState(new File(template.toURI()).exists(), "Missing Soy template %s", template);
+ } catch (URISyntaxException e) {
+ throw new IllegalStateException(e);
+ }
+ builder.add(template);
+ }
+ return builder.build().compileToTofu();
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
new file mode 100644
index 0000000..5b6d822
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
@@ -0,0 +1,239 @@
+// 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.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Queues;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.transport.resolver.FileResolver;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.eclipse.jgit.util.IO;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Default implementation of {@link GitilesAccess} with local repositories.
+ * <p>
+ * Repositories are scanned on-demand under the given path, configured by
+ * default from {@code gitiles.basePath}. There is no access control beyond what
+ * user the JVM is running under.
+ */
+public class DefaultAccess implements GitilesAccess {
+ private static final String ANONYMOUS_USER_KEY = "anonymous user";
+
+ public static class Factory implements GitilesAccess.Factory {
+ private final File basePath;
+ private final String canonicalBasePath;
+ private final String baseGitUrl;
+ private final FileResolver<HttpServletRequest> resolver;
+
+ Factory(File basePath, String baseGitUrl, FileResolver<HttpServletRequest> resolver)
+ throws IOException {
+ this.basePath = checkNotNull(basePath, "basePath");
+ this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl");
+ this.resolver = checkNotNull(resolver, "resolver");
+ this.canonicalBasePath = basePath.getCanonicalPath();
+ }
+
+ @Override
+ public GitilesAccess forRequest(HttpServletRequest req) {
+ String path = req.getPathInfo();
+ String repositoryPath;
+ if (path == null || path == "/") {
+ repositoryPath = null;
+ } else {
+ int slashPlus = path.indexOf("/+/");
+ if (slashPlus >= 0) {
+ repositoryPath = path.substring(0, slashPlus);
+ } else if (path.endsWith("/+")) {
+ repositoryPath = path.substring(0, path.length() - 2);
+ } else {
+ repositoryPath = path;
+ }
+ }
+ return newAccess(basePath, canonicalBasePath, baseGitUrl, resolver, req);
+ }
+
+ protected DefaultAccess newAccess(File basePath, String canonicalBasePath, String baseGitUrl,
+ FileResolver<HttpServletRequest> resolver, HttpServletRequest req) {
+ return new DefaultAccess(basePath, canonicalBasePath, baseGitUrl, resolver, req);
+ }
+ }
+
+ protected final File basePath;
+ protected final String canonicalBasePath;
+ protected final String baseGitUrl;
+ protected final FileResolver<HttpServletRequest> resolver;
+ protected final HttpServletRequest req;
+
+ protected DefaultAccess(File basePath, String canonicalBasePath, String baseGitUrl,
+ FileResolver<HttpServletRequest> resolver, HttpServletRequest req) {
+ this.basePath = checkNotNull(basePath, "basePath");
+ this.canonicalBasePath = checkNotNull(canonicalBasePath, "canonicalBasePath");
+ this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl");
+ this.resolver = checkNotNull(resolver, "resolver");
+ this.req = checkNotNull(req, "req");
+ }
+
+ @Override
+ public Map<String, RepositoryDescription> listRepositories(Set<String> branches)
+ throws IOException {
+ Map<String, RepositoryDescription> repos = Maps.newTreeMap();
+ for (Repository repo : scanRepositories(basePath, req)) {
+ repos.put(getRepositoryName(repo), buildDescription(repo, branches));
+ repo.close();
+ }
+ return repos;
+ }
+
+ @Override
+ public Object getUserKey() {
+ // Always return the same anonymous user key (effectively running with the
+ // same user permissions as the JVM). Subclasses may override this behavior.
+ return ANONYMOUS_USER_KEY;
+ }
+
+ @Override
+ public String getRepositoryName() {
+ return getRepositoryName(ServletUtils.getRepository(req));
+ }
+
+ @Override
+ public RepositoryDescription getRepositoryDescription() throws IOException {
+ return buildDescription(ServletUtils.getRepository(req), Collections.<String> emptySet());
+ }
+
+ private String getRepositoryName(Repository repo) {
+ String path = getRelativePath(repo);
+ if (repo.isBare() && path.endsWith(".git")) {
+ path = path.substring(0, path.length() - 4);
+ }
+ return path;
+ }
+
+ private String getRelativePath(Repository repo) {
+ String path = repo.isBare() ? repo.getDirectory().getPath() : repo.getDirectory().getParent();
+ if (repo.isBare()) {
+ path = repo.getDirectory().getPath();
+ if (path.endsWith(".git")) {
+ path = path.substring(0, path.length() - 4);
+ }
+ } else {
+ path = repo.getDirectory().getParent();
+ }
+ return getRelativePath(path);
+ }
+
+ private String getRelativePath(String path) {
+ String base = basePath.getPath();
+ if (path.startsWith(base)) {
+ return path.substring(base.length() + 1);
+ }
+ if (path.startsWith(canonicalBasePath)) {
+ return path.substring(canonicalBasePath.length() + 1);
+ }
+ throw new IllegalStateException(String.format(
+ "Repository path %s is outside base path %s", path, base));
+ }
+
+ private String loadDescriptionText(Repository repo) throws IOException {
+ String desc = null;
+ StoredConfig config = repo.getConfig();
+ IOException configError = null;
+ try {
+ config.load();
+ desc = config.getString("gitweb", null, "description");
+ } catch (ConfigInvalidException e) {
+ configError = new IOException(e);
+ }
+ if (desc == null) {
+ File descFile = new File(repo.getDirectory(), "description");
+ if (descFile.exists()) {
+ desc = new String(IO.readFully(descFile));
+ } else if (configError != null) {
+ throw configError;
+ }
+ }
+ return desc;
+ }
+
+ private RepositoryDescription buildDescription(Repository repo, Set<String> branches)
+ throws IOException {
+ RepositoryDescription desc = new RepositoryDescription();
+ desc.name = getRepositoryName(repo);
+ desc.cloneUrl = baseGitUrl + getRelativePath(repo);
+ desc.description = loadDescriptionText(repo);
+ if (!branches.isEmpty()) {
+ desc.branches = Maps.newLinkedHashMap();
+ for (String name : branches) {
+ Ref ref = repo.getRef(normalizeRefName(name));
+ if ((ref != null) && (ref.getObjectId() != null)) {
+ desc.branches.put(name, ref.getObjectId().name());
+ }
+ }
+ }
+ return desc;
+ }
+
+ private static String normalizeRefName(String name) {
+ if (name.startsWith("refs/")) {
+ return name;
+ }
+ return "refs/heads/" + name;
+ }
+
+ private Collection<Repository> scanRepositories(final File basePath, final HttpServletRequest req) throws IOException {
+ List<Repository> repos = Lists.newArrayList();
+ Queue<File> todo = Queues.newArrayDeque();
+ File[] baseFiles = basePath.listFiles();
+ if (baseFiles == null) {
+ throw new IOException("base path is not a directory: " + basePath.getPath());
+ }
+ todo.addAll(Arrays.asList(baseFiles));
+ while (!todo.isEmpty()) {
+ File file = todo.remove();
+ try {
+ repos.add(resolver.open(req, getRelativePath(file.getPath())));
+ } catch (RepositoryNotFoundException e) {
+ File[] children = file.listFiles();
+ if (children != null) {
+ todo.addAll(Arrays.asList(children));
+ }
+ } catch (ServiceNotEnabledException e) {
+ throw new IOException(e);
+ }
+ }
+ return repos;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java
new file mode 100644
index 0000000..f4bd1fb
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java
@@ -0,0 +1,59 @@
+// 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 com.google.common.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.Resources;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.tofu.SoyTofu;
+
+import java.net.URL;
+import java.util.Map;
+
+/** Renderer that precompiles Soy and uses static precompiled CSS. */
+public class DefaultRenderer extends Renderer {
+ private final SoyTofu tofu;
+
+ DefaultRenderer() {
+ this("", null);
+ }
+
+ public DefaultRenderer(String staticPrefix, URL customTemplates) {
+ this(ImmutableMap.<String, String> of(), staticPrefix, customTemplates);
+ }
+
+ public DefaultRenderer(Map<String, String> globals, String staticPrefix, URL customTemplates) {
+ super(
+ new Function<String, URL>() {
+ @Override
+ public URL apply(String name) {
+ return Resources.getResource(Renderer.class, "templates/" + name);
+ }
+ },
+ globals, staticPrefix, customTemplates);
+ SoyFileSet.Builder builder = new SoyFileSet.Builder()
+ .setCompileTimeGlobals(this.globals);
+ for (URL template : templates) {
+ builder.add(template);
+ }
+ tofu = builder.build().compileToTofu();
+ }
+
+ @Override
+ protected SoyTofu getTofu() {
+ return tofu;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java
new file mode 100644
index 0000000..6495b5d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java
@@ -0,0 +1,60 @@
+// 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 java.net.InetAddress;
+import java.net.UnknownHostException;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Default implementation of {@link GitilesUrls}.
+ * <p>
+ * This implementation uses statically-configured defaults, and thus assumes
+ * that the servlet is running a single virtual host.
+ */
+class DefaultUrls implements GitilesUrls {
+ private final String canonicalHostName;
+ private final String baseGitUrl;
+ private final String baseGerritUrl;
+
+ DefaultUrls(String canonicalHostName, String baseGitUrl, String baseGerritUrl)
+ throws UnknownHostException {
+ if (canonicalHostName != null) {
+ this.canonicalHostName = canonicalHostName;
+ } else {
+ this.canonicalHostName = InetAddress.getLocalHost().getCanonicalHostName();
+ }
+ this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl");
+ this.baseGerritUrl = checkNotNull(baseGerritUrl, "baseGerritUrl");
+ }
+
+ @Override
+ public String getHostName(HttpServletRequest req) {
+ return canonicalHostName;
+ }
+
+ @Override
+ public String getBaseGitUrl(HttpServletRequest req) {
+ return baseGitUrl;
+ }
+
+ @Override
+ public String getBaseGerritUrl(HttpServletRequest req) {
+ return baseGerritUrl;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java
new file mode 100644
index 0000000..2408cd7
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java
@@ -0,0 +1,162 @@
+// 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 static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import com.google.common.base.Charsets;
+
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.http.server.ServletUtils;
+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 org.eclipse.jgit.treewalk.AbstractTreeIterator;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+import org.eclipse.jgit.treewalk.EmptyTreeIterator;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with all the diffs for a commit. */
+public class DiffServlet extends BaseServlet {
+ private static final String PLACEHOLDER = "id=\"DIFF_OUTPUT_BLOCK\"";
+
+ private final Linkifier linkifier;
+
+ public DiffServlet(Renderer renderer, Linkifier linkifier) {
+ super(renderer);
+ this.linkifier = checkNotNull(linkifier, "linkifier");
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ Repository repo = ServletUtils.getRepository(req);
+
+ RevWalk walk = new RevWalk(repo);
+ try {
+ boolean showCommit;
+ AbstractTreeIterator oldTree;
+ AbstractTreeIterator newTree;
+ try {
+ // If we are viewing the diff between a commit and one of its parents,
+ // include the commit detail in the rendered page.
+ showCommit = isParentOf(walk, view.getOldRevision(), view.getRevision());
+ oldTree = getTreeIterator(walk, view.getOldRevision().getId());
+ newTree = getTreeIterator(walk, view.getRevision().getId());
+ } catch (MissingObjectException e) {
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ } catch (IncorrectObjectTypeException e) {
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+
+ Map<String, Object> data = getData(req);
+ data.put("title", "Diff - " + view.getRevisionRange());
+ if (showCommit) {
+ data.put("commit", new CommitSoyData(linkifier, req, repo, walk, view)
+ .toSoyData(walk.parseCommit(view.getRevision().getId())));
+ }
+ if (!data.containsKey("repositoryName") && (view.getRepositoryName() != null)) {
+ data.put("repositoryName", view.getRepositoryName());
+ }
+ if (!data.containsKey("breadcrumbs")) {
+ data.put("breadcrumbs", view.getBreadcrumbs());
+ }
+
+ String[] html = renderAndSplit(data);
+ res.setStatus(HttpServletResponse.SC_OK);
+ res.setContentType(FormatType.HTML.getMimeType());
+ res.setCharacterEncoding(Charsets.UTF_8.name());
+ setCacheHeaders(req, res);
+
+ OutputStream out = res.getOutputStream();
+ try {
+ out.write(html[0].getBytes(Charsets.UTF_8));
+ formatHtmlDiff(out, repo, walk, oldTree, newTree, view.getTreePath());
+ out.write(html[1].getBytes(Charsets.UTF_8));
+ } finally {
+ out.close();
+ }
+ } finally {
+ walk.release();
+ }
+ }
+
+ private static boolean isParentOf(RevWalk walk, Revision oldRevision, Revision newRevision)
+ throws MissingObjectException, IncorrectObjectTypeException, IOException {
+ RevCommit newCommit = walk.parseCommit(newRevision.getId());
+ if (newCommit.getParentCount() > 0) {
+ return Arrays.asList(newCommit.getParents()).contains(oldRevision.getId());
+ } else {
+ return oldRevision == Revision.NULL;
+ }
+ }
+
+ private String[] renderAndSplit(Map<String, Object> data) {
+ String html = renderer.newRenderer("gitiles.diffDetail")
+ .setData(data)
+ .render();
+ int id = html.indexOf(PLACEHOLDER);
+ if (id < 0) {
+ throw new IllegalStateException("Template must contain " + PLACEHOLDER);
+ }
+
+ int lt = html.lastIndexOf('<', id);
+ int gt = html.indexOf('>', id + PLACEHOLDER.length());
+ return new String[] {html.substring(0, lt), html.substring(gt + 1)};
+ }
+
+ private void formatHtmlDiff(OutputStream out,
+ Repository repo, RevWalk walk,
+ AbstractTreeIterator oldTree, AbstractTreeIterator newTree,
+ String path)
+ throws IOException {
+ DiffFormatter diff = new HtmlDiffFormatter(renderer, out);
+ try {
+ if (!path.equals("")) {
+ diff.setPathFilter(PathFilter.create(path));
+ }
+ diff.setRepository(repo);
+ diff.setDetectRenames(true);
+ diff.format(oldTree, newTree);
+ } finally {
+ diff.release();
+ }
+ }
+
+ private static AbstractTreeIterator getTreeIterator(RevWalk walk, ObjectId id)
+ throws IOException {
+ if (!id.equals(ObjectId.zeroId())) {
+ CanonicalTreeParser p = new CanonicalTreeParser();
+ p.reset(walk.getObjectReader(), walk.parseTree(id));
+ return p;
+ } else {
+ return new EmptyTreeIterator();
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java b/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java
new file mode 100644
index 0000000..9417a4c
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java
@@ -0,0 +1,76 @@
+// 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 com.google.common.base.Strings;
+import com.google.common.net.HttpHeaders;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Type of formatting to use in the response to the client. */
+public enum FormatType {
+ HTML("text/html"),
+ TEXT("text/plain"),
+ JSON("application/json"),
+ DEFAULT("*/*");
+
+ private static final String FORMAT_TYPE_ATTRIBUTE = FormatType.class.getName();
+
+ public static FormatType getFormatType(HttpServletRequest req) {
+ FormatType result = (FormatType) req.getAttribute(FORMAT_TYPE_ATTRIBUTE);
+ if (result != null) {
+ return result;
+ }
+
+ String format = req.getParameter("format");
+ if (format != null) {
+ for (FormatType type : FormatType.values()) {
+ if (format.equalsIgnoreCase(type.name())) {
+ return set(req, type);
+ }
+ }
+ throw new IllegalArgumentException("Invalid format " + format);
+ }
+
+ String accept = req.getHeader(HttpHeaders.ACCEPT);
+ if (Strings.isNullOrEmpty(accept)) {
+ return set(req, DEFAULT);
+ }
+
+ for (String p : accept.split("[ ,;][ ,;]*")) {
+ for (FormatType type : FormatType.values()) {
+ if (p.equals(type.mimeType)) {
+ return set(req, type != HTML ? type : DEFAULT);
+ }
+ }
+ }
+ return set(req, DEFAULT);
+ }
+
+ private static FormatType set(HttpServletRequest req, FormatType format) {
+ req.setAttribute(FORMAT_TYPE_ATTRIBUTE, format);
+ return format;
+ }
+
+ private final String mimeType;
+
+ private FormatType(String mimeType) {
+ this.mimeType = mimeType;
+ }
+
+ public String getMimeType() {
+ return mimeType;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
new file mode 100644
index 0000000..648d709
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
@@ -0,0 +1,68 @@
+// 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 org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Git storage interface for Gitiles.
+ * <p>
+ * Each instance is associated with a single end-user request, which implicitly
+ * includes information about the host and repository.
+ */
+public interface GitilesAccess {
+ /** Factory for per-request access. */
+ public interface Factory {
+ public GitilesAccess forRequest(HttpServletRequest req);
+ }
+
+ /**
+ * List repositories on the host.
+ *
+ * @param branches branches to list along with each repository.
+ * @return map of repository names to descriptions.
+ * @throws ServiceNotEnabledException to trigger an HTTP 403 Forbidden
+ * (matching behavior in {@link org.eclipse.jgit.http.server.RepositoryFilter}).
+ * @throws ServiceNotAuthorizedException to trigger an HTTP 401 Unauthorized
+ * (matching behavior in {@link org.eclipse.jgit.http.server.RepositoryFilter}).
+ * @throws IOException if an error occurred.
+ */
+ public Map<String, RepositoryDescription> listRepositories(Set<String> branches)
+ throws ServiceNotEnabledException, ServiceNotAuthorizedException, IOException;
+
+ /**
+ * @return an opaque object that uniquely identifies the end-user making the
+ * request, and supports {@link #equals(Object)} and {@link #hashCode()}.
+ * Never null.
+ */
+ public Object getUserKey();
+
+ /** @return the repository name associated with the request. */
+ public String getRepositoryName();
+
+ /**
+ * @return the description attached to the repository of this request.
+ * @throws IOException an error occurred reading the description string from
+ * the repository.
+ */
+ public RepositoryDescription getRepositoryDescription() throws IOException;
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
new file mode 100644
index 0000000..b2cf9ec
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -0,0 +1,362 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+import static com.google.gitiles.GitilesServlet.STATIC_PREFIX;
+import static com.google.gitiles.ViewFilter.getRegexGroup;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.http.server.RepositoryFilter;
+import org.eclipse.jgit.http.server.glue.MetaFilter;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.resolver.FileResolver;
+import org.eclipse.jgit.transport.resolver.RepositoryResolver;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.eclipse.jgit.util.FS;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.UnknownHostException;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * MetaFilter to serve Gitiles.
+ * <p>
+ * Do not use directly; use {@link GitilesServlet}.
+ */
+class GitilesFilter extends MetaFilter {
+ private static final String CONFIG_PATH_PARAM = "configPath";
+
+ // The following regexes have the following capture groups:
+ // 1. The whole string, which causes RegexPipeline to set REGEX_GROUPS but
+ // not otherwise modify the original request.
+ // 2. The repository name part, before /<CMD>.
+ // 3. The command, <CMD>, with no slashes and beginning with +. Commands have
+ // names analogous to (but not exactly the same as) git command names, such
+ // as "+log" and "+show". The bare command "+" maps to one of the other
+ // commands based on the revision/path, and may change over time.
+ // 4. The revision/path part, after /<CMD> (called just "path" below). This is
+ // split into a revision and a path by RevisionParser.
+
+ private static final String CMD = "\\+[a-z0-9-]*";
+
+ @VisibleForTesting
+ static final Pattern ROOT_REGEX = Pattern.compile(""
+ + "^( " // 1. Everything
+ + " /* " // Excess slashes
+ + " (/) " // 2. Repo name (just slash)
+ + " () " // 3. Command
+ + " () " // 4. Path
+ + ")$ ",
+ Pattern.COMMENTS);
+
+ @VisibleForTesting
+ static final Pattern REPO_REGEX = Pattern.compile(""
+ + "^( " // 1. Everything
+ + " /* " // Excess slashes
+ + " ( " // 2. Repo name
+ + " / " // Leading slash
+ + " (?:.(?! " // Anything, as long as it's not followed by...
+ + " /" + CMD + "/ " // the special "/<CMD>/" separator,
+ + " |/" + CMD + "$ " // or "/<CMD>" at the end of the string
+ + " ))*? "
+ + " ) "
+ + " /* " // Trailing slashes
+ + " () " // 3. Command
+ + " () " // 4. Path
+ + ")$ ",
+ Pattern.COMMENTS);
+
+ @VisibleForTesting
+ static final Pattern REPO_PATH_REGEX = Pattern.compile(""
+ + "^( " // 1. Everything
+ + " /* " // Excess slashes
+ + " ( " // 2. Repo name
+ + " / " // Leading slash
+ + " .*? " // Anything, non-greedy
+ + " ) "
+ + " /(" + CMD + ")" // 3. Command
+ + " ( " // 4. Path
+ + " (?:/.*)? " // Slash path, or nothing.
+ + " ) "
+ + ")$ ",
+ Pattern.COMMENTS);
+
+ private static class DispatchFilter extends AbstractHttpFilter {
+ private final ListMultimap<GitilesView.Type, Filter> filters;
+ private final Map<GitilesView.Type, HttpServlet> servlets;
+
+ private DispatchFilter(ListMultimap<GitilesView.Type, Filter> filters,
+ Map<GitilesView.Type, HttpServlet> servlets) {
+ this.filters = LinkedListMultimap.create(filters);
+ this.servlets = ImmutableMap.copyOf(servlets);
+ for (GitilesView.Type type : GitilesView.Type.values()) {
+ checkState(servlets.containsKey(type), "Missing handler for view %s", type);
+ }
+ }
+
+ @Override
+ public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+ throws IOException, ServletException {
+ GitilesView view = checkNotNull(ViewFilter.getView(req));
+ final Iterator<Filter> itr = filters.get(view.getType()).iterator();
+ final HttpServlet servlet = servlets.get(view.getType());
+ new FilterChain() {
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse res)
+ throws IOException, ServletException {
+ if (itr.hasNext()) {
+ itr.next().doFilter(req, res, this);
+ } else {
+ servlet.service(req, res);
+ }
+ }
+ }.doFilter(req, res);
+ }
+ }
+
+ private final ListMultimap<GitilesView.Type, Filter> filters = LinkedListMultimap.create();
+ private final Map<GitilesView.Type, HttpServlet> servlets = Maps.newHashMap();
+
+ private Renderer renderer;
+ private GitilesUrls urls;
+ private Linkifier linkifier;
+ private GitilesAccess.Factory accessFactory;
+ private RepositoryResolver<HttpServletRequest> resolver;
+ private VisibilityCache visibilityCache;
+ private boolean initialized;
+
+ GitilesFilter() {
+ }
+
+ GitilesFilter(
+ Renderer renderer,
+ GitilesUrls urls,
+ GitilesAccess.Factory accessFactory,
+ final RepositoryResolver<HttpServletRequest> resolver,
+ VisibilityCache visibilityCache) {
+ this.renderer = checkNotNull(renderer, "renderer");
+ this.urls = checkNotNull(urls, "urls");
+ this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+ this.visibilityCache = checkNotNull(visibilityCache, "visibilityCache");
+ this.linkifier = new Linkifier(urls);
+ this.resolver = wrapResolver(resolver);
+ }
+
+ @Override
+ public synchronized void init(FilterConfig config) throws ServletException {
+ super.init(config);
+ setDefaultFields(config);
+
+ for (GitilesView.Type type : GitilesView.Type.values()) {
+ if (!servlets.containsKey(type)) {
+ servlets.put(type, getDefaultHandler(type));
+ }
+ }
+
+ Filter repositoryFilter = new RepositoryFilter(resolver);
+ Filter viewFilter = new ViewFilter(accessFactory, urls, visibilityCache);
+ Filter dispatchFilter = new DispatchFilter(filters, servlets);
+ String browserCssName;
+ String prettifyCssName;
+ String prettifyJsName;
+
+ serveRegex(ROOT_REGEX)
+ .through(viewFilter)
+ .through(dispatchFilter);
+
+ serveRegex(REPO_REGEX)
+ .through(repositoryFilter)
+ .through(viewFilter)
+ .through(dispatchFilter);
+
+ serveRegex(REPO_PATH_REGEX)
+ .through(repositoryFilter)
+ .through(viewFilter)
+ .through(dispatchFilter);
+
+ initialized = true;
+ }
+
+ public synchronized BaseServlet getDefaultHandler(GitilesView.Type view) {
+ checkNotInitialized();
+ switch (view) {
+ case HOST_INDEX:
+ return new HostIndexServlet(renderer, urls, accessFactory);
+ case REPOSITORY_INDEX:
+ return new RepositoryIndexServlet(renderer, accessFactory);
+ case REVISION:
+ return new RevisionServlet(renderer, linkifier);
+ case PATH:
+ return new PathServlet(renderer);
+ case DIFF:
+ return new DiffServlet(renderer, linkifier);
+ case LOG:
+ return new LogServlet(renderer, linkifier);
+ default:
+ throw new IllegalArgumentException("Invalid view type: " + view);
+ }
+ }
+
+ synchronized void addFilter(GitilesView.Type view, Filter filter) {
+ checkNotInitialized();
+ filters.put(checkNotNull(view, "view"), checkNotNull(filter, "filter for %s", view));
+ }
+
+ synchronized void setHandler(GitilesView.Type view, HttpServlet handler) {
+ checkNotInitialized();
+ servlets.put(checkNotNull(view, "view"),
+ checkNotNull(handler, "handler for %s", view));
+ }
+
+ private synchronized void checkNotInitialized() {
+ checkState(!initialized, "Gitiles already initialized");
+ }
+
+ private static RepositoryResolver<HttpServletRequest> wrapResolver(
+ final RepositoryResolver<HttpServletRequest> resolver) {
+ checkNotNull(resolver, "resolver");
+ return new RepositoryResolver<HttpServletRequest>() {
+ @Override
+ public Repository open(HttpServletRequest req, String name)
+ throws RepositoryNotFoundException, ServiceNotAuthorizedException,
+ ServiceNotEnabledException, ServiceMayNotContinueException {
+ return resolver.open(req, ViewFilter.trimLeadingSlash(getRegexGroup(req, 1)));
+ }
+ };
+ }
+
+ private void setDefaultFields(FilterConfig config) throws ServletException {
+ if (renderer != null && urls != null && accessFactory != null && resolver != null
+ && visibilityCache != null) {
+ return;
+ }
+ String configPath = config.getInitParameter(CONFIG_PATH_PARAM);
+ if (configPath == null) {
+ throw new ServletException("Missing required parameter " + configPath);
+ }
+ FileBasedConfig jgitConfig = new FileBasedConfig(new File(configPath), FS.DETECTED);
+ try {
+ jgitConfig.load();
+ } catch (IOException e) {
+ throw new ServletException(e);
+ } catch (ConfigInvalidException e) {
+ throw new ServletException(e);
+ }
+
+ if (renderer == null) {
+ String staticPrefix = config.getServletContext().getContextPath() + STATIC_PREFIX;
+ String customTemplates = jgitConfig.getString("gitiles", null, "customTemplates");
+ // TODO(dborowitz): Automatically set to true when run with mvn jetty:run.
+ if (jgitConfig.getBoolean("gitiles", null, "reloadTemplates", false)) {
+ renderer = new DebugRenderer(staticPrefix, customTemplates,
+ Joiner.on(File.separatorChar).join(System.getProperty("user.dir"),
+ "gitiles-servlet", "src", "main", "resources",
+ "com", "google", "gitiles", "templates"));
+ } else {
+ renderer = new DefaultRenderer(staticPrefix, Renderer.toFileURL(customTemplates));
+ }
+ }
+ if (urls == null) {
+ try {
+ urls = new DefaultUrls(
+ jgitConfig.getString("gitiles", null, "canonicalHostName"),
+ getBaseGitUrl(jgitConfig),
+ getGerritUrl(jgitConfig));
+ } catch (UnknownHostException e) {
+ throw new ServletException(e);
+ }
+ }
+ linkifier = new Linkifier(urls);
+ if (accessFactory == null || resolver == null) {
+ String basePath = jgitConfig.getString("gitiles", null, "basePath");
+ if (basePath == null) {
+ throw new ServletException("gitiles.basePath not set");
+ }
+ boolean exportAll = jgitConfig.getBoolean("gitiles", null, "exportAll", false);
+
+ FileResolver<HttpServletRequest> fileResolver;
+ if (resolver == null) {
+ fileResolver = new FileResolver<HttpServletRequest>(new File(basePath), exportAll);
+ resolver = wrapResolver(fileResolver);
+ } else if (resolver instanceof FileResolver) {
+ fileResolver = (FileResolver<HttpServletRequest>) resolver;
+ } else {
+ fileResolver = null;
+ }
+ if (accessFactory == null) {
+ checkState(fileResolver != null, "need a FileResolver when GitilesAccess.Factory not set");
+ try {
+ accessFactory = new DefaultAccess.Factory(
+ new File(basePath),
+ getBaseGitUrl(jgitConfig),
+ fileResolver);
+ } catch (IOException e) {
+ throw new ServletException(e);
+ }
+ }
+ }
+ if (visibilityCache == null) {
+ if (jgitConfig.getSubsections("cache").contains("visibility")) {
+ visibilityCache =
+ new VisibilityCache(false, ConfigUtil.getCacheBuilder(jgitConfig, "visibility"));
+ } else {
+ visibilityCache = new VisibilityCache(false);
+ }
+ }
+ }
+
+ private static String getBaseGitUrl(Config config) throws ServletException {
+ String baseGitUrl = config.getString("gitiles", null, "baseGitUrl");
+ if (baseGitUrl == null) {
+ throw new ServletException("gitiles.baseGitUrl not set");
+ }
+ return baseGitUrl;
+ }
+
+ private static String getGerritUrl(Config config) throws ServletException {
+ String gerritUrl = config.getString("gitiles", null, "gerritUrl");
+ if (gerritUrl == null) {
+ throw new ServletException("gitiles.gerritUrl not set");
+ }
+ return gerritUrl;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java
new file mode 100644
index 0000000..2cdbc02
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java
@@ -0,0 +1,113 @@
+// 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 org.eclipse.jgit.http.server.glue.MetaServlet;
+import org.eclipse.jgit.transport.resolver.RepositoryResolver;
+
+import java.util.Enumeration;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Servlet to serve Gitiles.
+ * <p>
+ * This servlet can either be constructed manually with its dependencies, or
+ * configured to use default implementations for the Gitiles interfaces. To
+ * configure the defaults, you must provide a single init parameter
+ * "configPath", which is the path to a git config file containing additional
+ * configuration.
+ */
+public class GitilesServlet extends MetaServlet {
+ /** The prefix from which static resources are served. */
+ public static final String STATIC_PREFIX = "/+static/";
+
+ public GitilesServlet(Renderer renderer,
+ GitilesUrls urls,
+ GitilesAccess.Factory accessFactory,
+ RepositoryResolver<HttpServletRequest> resolver,
+ VisibilityCache visibilityCache) {
+ super(new GitilesFilter(renderer, urls, accessFactory, resolver, visibilityCache));
+ }
+
+ public GitilesServlet() {
+ super(new GitilesFilter());
+ }
+
+ @Override
+ protected GitilesFilter getDelegateFilter() {
+ return (GitilesFilter) super.getDelegateFilter();
+ }
+
+ @Override
+ public void init(final ServletConfig config) throws ServletException {
+ getDelegateFilter().init(new FilterConfig() {
+ @Override
+ public String getFilterName() {
+ return getDelegateFilter().getClass().getName();
+ }
+
+ @Override
+ public String getInitParameter(String name) {
+ return config.getInitParameter(name);
+ }
+
+ @SuppressWarnings("rawtypes")
+ @Override
+ public Enumeration getInitParameterNames() {
+ return config.getInitParameterNames();
+ }
+
+ @Override
+ public ServletContext getServletContext() {
+ return config.getServletContext();
+ }
+ });
+ }
+
+ /**
+ * Add a custom filter for a view.
+ * <p>
+ * Must be called before initializing the servlet.
+ *
+ * @param view view type.
+ * @param filter filter.
+ */
+ public void addFilter(GitilesView.Type view, Filter filter) {
+ getDelegateFilter().addFilter(view, filter);
+ }
+
+ /**
+ * Set a custom handler for a view.
+ * <p>
+ * Must be called before initializing the servlet.
+ *
+ * @param view view type.
+ * @param handler handler.
+ */
+ public void setHandler(GitilesView.Type view, HttpServlet handler) {
+ getDelegateFilter().setHandler(view, handler);
+ }
+
+ public BaseServlet getDefaultHandler(GitilesView.Type view) {
+ return getDelegateFilter().getDefaultHandler(view);
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java
new file mode 100644
index 0000000..fb7ff3b
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java
@@ -0,0 +1,79 @@
+// 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 com.google.common.base.Charsets;
+import com.google.common.base.Function;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Interface for URLs displayed on source browsing pages. */
+public interface GitilesUrls {
+ /**
+ * Escapes repository or path names to be safely embedded into a URL.
+ * <p>
+ * This escape implementation escapes a repository or path name such as
+ * "foo/bar</child" to appear as "foo/bar%3C/child". Spaces are escaped as
+ * "%20". Its purpose is to escape a repository name to be safe for inclusion
+ * in the path component of the URL, where "/" is a valid character that
+ * should not be encoded, while almost any other non-alpha, non-numeric
+ * character will be encoded using URL style encoding.
+ */
+ public static final Function<String, String> NAME_ESCAPER = new Function<String, String>() {
+ @Override
+ public String apply(String s) {
+ try {
+ return URLEncoder.encode(s, Charsets.UTF_8.name())
+ .replace("%2F", "/")
+ .replace("%2f", "/")
+ .replace("+", "%20")
+ .replace("%2B", "+")
+ .replace("%2b", "+");
+ } catch (UnsupportedEncodingException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ };
+
+ /**
+ * Return the name of the host from the request.
+ *
+ * Used in various user-visible text, like "MyHost Git Repositories".
+ *
+ * @param req request.
+ * @return host name; may be null.
+ */
+ public String getHostName(HttpServletRequest req);
+
+ /**
+ * Return the base URL for git repositories on this host.
+ *
+ * @param req request.
+ * @return base URL for git repositories.
+ */
+ public String getBaseGitUrl(HttpServletRequest req);
+
+ /**
+ * Return the base URL for Gerrit projects on this host.
+ *
+ * @param req request.
+ * @return base URL for Gerrit Code Review, or null if Gerrit is not
+ * configured.
+ */
+ public String getBaseGerritUrl(HttpServletRequest req);
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
new file mode 100644
index 0000000..ad685c4
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -0,0 +1,563 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+import static com.google.gitiles.GitilesUrls.NAME_ESCAPER;
+
+import com.google.common.base.Charsets;
+import com.google.common.base.Objects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimaps;
+
+import org.eclipse.jgit.revwalk.RevObject;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Information about a view in Gitiles.
+ * <p>
+ * Views are uniquely identified by a type, and dispatched to servlet types by
+ * {@link GitilesServlet}. This class contains the list of all types, as
+ * well as some methods containing basic information parsed from the URL.
+ * Construction happens in {@link ViewFilter}.
+ */
+public class GitilesView {
+ /** All the possible view types supported in the application. */
+ public static enum Type {
+ HOST_INDEX,
+ REPOSITORY_INDEX,
+ REVISION,
+ PATH,
+ DIFF,
+ LOG;
+ }
+
+ /** Builder for views. */
+ public static class Builder {
+ private final Type type;
+ private final ListMultimap<String, String> params = LinkedListMultimap.create();
+
+ private String hostName;
+ private String servletPath;
+ private String repositoryName;
+ private Revision revision = Revision.NULL;
+ private Revision oldRevision = Revision.NULL;
+ private String path;
+ private String anchor;
+
+ private Builder(Type type) {
+ this.type = type;
+ }
+
+ public Builder copyFrom(GitilesView other) {
+ hostName = other.hostName;
+ servletPath = other.servletPath;
+ switch (type) {
+ case LOG:
+ case DIFF:
+ oldRevision = other.oldRevision;
+ // Fallthrough.
+ case PATH:
+ path = other.path;
+ // Fallthrough.
+ case REVISION:
+ revision = other.revision;
+ // Fallthrough.
+ case REPOSITORY_INDEX:
+ repositoryName = other.repositoryName;
+ }
+ // Don't copy params.
+ return this;
+ }
+
+ public Builder copyFrom(HttpServletRequest req) {
+ return copyFrom(ViewFilter.getView(req));
+ }
+
+ public Builder setHostName(String hostName) {
+ this.hostName = checkNotNull(hostName);
+ return this;
+ }
+
+ public String getHostName() {
+ return hostName;
+ }
+
+ public Builder setServletPath(String servletPath) {
+ this.servletPath = checkNotNull(servletPath);
+ return this;
+ }
+
+ public String getServletPath() {
+ return servletPath;
+ }
+
+ public Builder setRepositoryName(String repositoryName) {
+ switch (type) {
+ case HOST_INDEX:
+ throw new IllegalStateException(String.format(
+ "cannot set repository name on %s view", type));
+ default:
+ this.repositoryName = checkNotNull(repositoryName);
+ return this;
+ }
+ }
+
+ public String getRepositoryName() {
+ return repositoryName;
+ }
+
+ public Builder setRevision(Revision revision) {
+ switch (type) {
+ case HOST_INDEX:
+ case REPOSITORY_INDEX:
+ throw new IllegalStateException(String.format("cannot set revision on %s view", type));
+ default:
+ this.revision = checkNotNull(revision);
+ return this;
+ }
+ }
+
+ public Builder setRevision(String name) {
+ return setRevision(Revision.named(name));
+ }
+
+ public Builder setRevision(RevObject obj) {
+ return setRevision(Revision.peeled(obj.name(), obj));
+ }
+
+ public Builder setRevision(String name, RevObject obj) {
+ return setRevision(Revision.peeled(name, obj));
+ }
+
+ public Revision getRevision() {
+ return revision;
+ }
+
+ public Builder setOldRevision(Revision revision) {
+ switch (type) {
+ case DIFF:
+ case LOG:
+ this.oldRevision = checkNotNull(revision);
+ return this;
+ default:
+ throw new IllegalStateException(
+ String.format("cannot set old revision on %s view", type));
+ }
+ }
+
+ public Builder setOldRevision(RevObject obj) {
+ return setOldRevision(Revision.peeled(obj.name(), obj));
+ }
+
+ public Builder setOldRevision(String name, RevObject obj) {
+ return setOldRevision(Revision.peeled(name, obj));
+ }
+
+ public Revision getOldRevision() {
+ return revision;
+ }
+
+ public Builder setTreePath(String path) {
+ switch (type) {
+ case PATH:
+ case DIFF:
+ this.path = maybeTrimLeadingAndTrailingSlash(checkNotNull(path));
+ return this;
+ case LOG:
+ this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null;
+ return this;
+ default:
+ throw new IllegalStateException(String.format("cannot set path on %s view", type));
+ }
+ }
+
+ public String getTreePath() {
+ return path;
+ }
+
+ public Builder putParam(String key, String value) {
+ params.put(key, value);
+ return this;
+ }
+
+ public Builder replaceParam(String key, String value) {
+ params.replaceValues(key, ImmutableList.of(value));
+ return this;
+ }
+
+ public Builder putAllParams(Map<String, String[]> params) {
+ for (Map.Entry<String, String[]> e : params.entrySet()) {
+ for (String v : e.getValue()) {
+ this.params.put(e.getKey(), v);
+ }
+ }
+ return this;
+ }
+
+ public ListMultimap<String, String> getParams() {
+ return params;
+ }
+
+ public Builder setAnchor(String anchor) {
+ this.anchor = anchor;
+ return this;
+ }
+
+ public String getAnchor() {
+ return anchor;
+ }
+
+ public GitilesView build() {
+ switch (type) {
+ case HOST_INDEX:
+ checkHostIndex();
+ break;
+ case REPOSITORY_INDEX:
+ checkRepositoryIndex();
+ break;
+ case REVISION:
+ checkRevision();
+ break;
+ case PATH:
+ checkPath();
+ break;
+ case DIFF:
+ checkDiff();
+ break;
+ case LOG:
+ checkLog();
+ break;
+ }
+ return new GitilesView(type, hostName, servletPath, repositoryName, revision,
+ oldRevision, path, params, anchor);
+ }
+
+ public String toUrl() {
+ return build().toUrl();
+ }
+
+ private void checkHostIndex() {
+ checkState(hostName != null, "missing hostName on %s view", type);
+ checkState(servletPath != null, "missing hostName on %s view", type);
+ }
+
+ private void checkRepositoryIndex() {
+ checkState(repositoryName != null, "missing repository name on %s view", type);
+ checkHostIndex();
+ }
+
+ private void checkRevision() {
+ checkState(revision != Revision.NULL, "missing revision on %s view", type);
+ checkRepositoryIndex();
+ }
+
+ private void checkDiff() {
+ checkPath();
+ }
+
+ private void checkLog() {
+ checkRevision();
+ }
+
+ private void checkPath() {
+ checkState(path != null, "missing path on %s view", type);
+ checkRevision();
+ }
+ }
+
+ public static Builder hostIndex() {
+ return new Builder(Type.HOST_INDEX);
+ }
+
+ public static Builder repositoryIndex() {
+ return new Builder(Type.REPOSITORY_INDEX);
+ }
+
+ public static Builder revision() {
+ return new Builder(Type.REVISION);
+ }
+
+ public static Builder path() {
+ return new Builder(Type.PATH);
+ }
+
+ public static Builder diff() {
+ return new Builder(Type.DIFF);
+ }
+
+ public static Builder log() {
+ return new Builder(Type.LOG);
+ }
+
+ private static String maybeTrimLeadingAndTrailingSlash(String str) {
+ if (str.startsWith("/")) {
+ str = str.substring(1);
+ }
+ return !str.isEmpty() && str.endsWith("/") ? str.substring(0, str.length() - 1) : str;
+ }
+
+ private final Type type;
+ private final String hostName;
+ private final String servletPath;
+ private final String repositoryName;
+ private final Revision revision;
+ private final Revision oldRevision;
+ private final String path;
+ private final ListMultimap<String, String> params;
+ private final String anchor;
+
+ private GitilesView(Type type,
+ String hostName,
+ String servletPath,
+ String repositoryName,
+ Revision revision,
+ Revision oldRevision,
+ String path,
+ ListMultimap<String, String> params,
+ String anchor) {
+ this.type = type;
+ this.hostName = hostName;
+ this.servletPath = servletPath;
+ this.repositoryName = repositoryName;
+ this.revision = Objects.firstNonNull(revision, Revision.NULL);
+ this.oldRevision = Objects.firstNonNull(oldRevision, Revision.NULL);
+ this.path = path;
+ this.params = Multimaps.unmodifiableListMultimap(params);
+ this.anchor = anchor;
+ }
+
+ public String getHostName() {
+ return hostName;
+ }
+
+ public String getServletPath() {
+ return servletPath;
+ }
+
+ public String getRepositoryName() {
+ return repositoryName;
+ }
+
+ public Revision getRevision() {
+ return revision;
+ }
+
+ public Revision getOldRevision() {
+ return oldRevision;
+ }
+
+ public String getRevisionRange() {
+ if (oldRevision == Revision.NULL) {
+ switch (type) {
+ case LOG:
+ case DIFF:
+ // For types that require two revisions, NULL indicates the empty
+ // tree/commit.
+ return revision.getName() + "^!";
+ default:
+ // For everything else NULL indicates it is not a range, just a single
+ // revision.
+ return null;
+ }
+ } else if (type == Type.DIFF && isFirstParent(revision, oldRevision)) {
+ return revision.getName() + "^!";
+ } else {
+ return oldRevision.getName() + ".." + revision.getName();
+ }
+ }
+
+ public String getTreePath() {
+ return path;
+ }
+
+ public ListMultimap<String, String> getParameters() {
+ return params;
+ }
+
+ public String getAnchor() {
+ return anchor;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ /** @return an escaped, relative URL representing this view. */
+ public String toUrl() {
+ StringBuilder url = new StringBuilder(servletPath).append('/');
+ ListMultimap<String, String> params = this.params;
+ switch (type) {
+ case HOST_INDEX:
+ params = LinkedListMultimap.create();
+ if (!this.params.containsKey("format")) {
+ params.put("format", FormatType.HTML.toString());
+ }
+ params.putAll(this.params);
+ break;
+ case REPOSITORY_INDEX:
+ url.append(repositoryName).append('/');
+ break;
+ case REVISION:
+ url.append(repositoryName).append("/+");
+ if (!getRevision().nameIsId()) {
+ url.append("show"); // Default for /+/master is +log.
+ }
+ url.append('/').append(revision.getName());
+ break;
+ case PATH:
+ url.append(repositoryName).append("/+/").append(revision.getName()).append('/')
+ .append(path);
+ break;
+ case DIFF:
+ url.append(repositoryName).append("/+/");
+ if (isFirstParent(revision, oldRevision)) {
+ url.append(revision.getName()).append("^!");
+ } else {
+ url.append(oldRevision.getName()).append("..").append(revision.getName());
+ }
+ url.append('/').append(path);
+ break;
+ case LOG:
+ url.append(repositoryName).append("/+");
+ if (getRevision().nameIsId() || oldRevision != Revision.NULL || path != null) {
+ // Default for /+/c0ffee/(...) is +show.
+ // Default for /+/c0ffee..deadbeef(/...) is +diff.
+ url.append("log");
+ }
+ url.append('/');
+ if (oldRevision != Revision.NULL) {
+ url.append(oldRevision.getName()).append("..");
+ }
+ url.append(revision.getName());
+ if (path != null) {
+ url.append('/').append(path);
+ }
+ break;
+ default:
+ throw new IllegalStateException("Unknown view type: " + type);
+ }
+ String baseUrl = NAME_ESCAPER.apply(url.toString());
+ url = new StringBuilder();
+ if (!params.isEmpty()) {
+ url.append('?').append(paramsToString(params));
+ }
+ if (!Strings.isNullOrEmpty(anchor)) {
+ url.append('#').append(NAME_ESCAPER.apply(anchor));
+ }
+ return baseUrl + url.toString();
+ }
+
+ public List<Map<String, String>> getBreadcrumbs() {
+ String path = this.path;
+ ImmutableList.Builder<Map<String, String>> breadcrumbs = ImmutableList.builder();
+ breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this)));
+ if (repositoryName != null) {
+ breadcrumbs.add(breadcrumb(repositoryName, repositoryIndex().copyFrom(this)));
+ }
+ if (type == Type.DIFF) {
+ // TODO(dborowitz): Tweak the breadcrumbs template to allow us to render
+ // separate links in "old..new".
+ breadcrumbs.add(breadcrumb(getRevisionRange(), diff().copyFrom(this).setTreePath("")));
+ } else if (type == Type.LOG) {
+ // TODO(dborowitz): Add something in the navigation area (probably not
+ // a breadcrumb) to allow switching between /+log/ and /+/.
+ if (oldRevision == Revision.NULL) {
+ breadcrumbs.add(breadcrumb(revision.getName(), log().copyFrom(this).setTreePath(null)));
+ } else {
+ breadcrumbs.add(breadcrumb(getRevisionRange(), log().copyFrom(this).setTreePath(null)));
+ }
+ path = Strings.emptyToNull(path);
+ } else if (revision != Revision.NULL) {
+ breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this)));
+ }
+ if (path != null) {
+ if (type != Type.LOG) { // The "." breadcrumb would be no different for LOG.
+ breadcrumbs.add(breadcrumb(".", copyWithPath().setTreePath("")));
+ }
+ StringBuilder cur = new StringBuilder();
+ boolean first = true;
+ for (String part : RevisionParser.PATH_SPLITTER.omitEmptyStrings().split(path)) {
+ if (!first) {
+ cur.append('/');
+ } else {
+ first = false;
+ }
+ cur.append(part);
+ breadcrumbs.add(breadcrumb(part, copyWithPath().setTreePath(cur.toString())));
+ }
+ }
+ return breadcrumbs.build();
+ }
+
+ private static Map<String, String> breadcrumb(String text, Builder url) {
+ return ImmutableMap.of("text", text, "url", url.toUrl());
+ }
+
+ private Builder copyWithPath() {
+ Builder copy;
+ switch (type) {
+ case DIFF:
+ copy = diff();
+ break;
+ case LOG:
+ copy = log();
+ break;
+ default:
+ copy = path();
+ break;
+ }
+ return copy.copyFrom(this);
+ }
+
+ private static boolean isFirstParent(Revision rev1, Revision rev2) {
+ return rev2 == Revision.NULL
+ || rev2.getName().equals(rev1.getName() + "^")
+ || rev2.getName().equals(rev1.getName() + "~1");
+ }
+
+ private static String paramsToString(ListMultimap<String, String> params) {
+ try {
+ StringBuilder sb = new StringBuilder();
+ boolean first = true;
+ for (Map.Entry<String, String> e : params.entries()) {
+ if (!first) {
+ sb.append('&');
+ } else {
+ first = false;
+ }
+ sb.append(URLEncoder.encode(e.getKey(), Charsets.UTF_8.name()));
+ if (!"".equals(e.getValue())) {
+ sb.append('=')
+ .append(URLEncoder.encode(e.getValue(), Charsets.UTF_8.name()));
+ }
+ }
+ return sb.toString();
+ } catch (UnsupportedEncodingException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
new file mode 100644
index 0000000..bf9ffd1
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
@@ -0,0 +1,199 @@
+// 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 static com.google.gitiles.FormatType.JSON;
+import static com.google.gitiles.FormatType.TEXT;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import com.google.common.net.HttpHeaders;
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves the top level index page for a Gitiles host. */
+public class HostIndexServlet extends BaseServlet {
+ private static final Logger log = LoggerFactory.getLogger(HostIndexServlet.class);
+
+ protected final GitilesUrls urls;
+ private final GitilesAccess.Factory accessFactory;
+
+ public HostIndexServlet(Renderer renderer, GitilesUrls urls,
+ GitilesAccess.Factory accessFactory) {
+ super(renderer);
+ this.urls = checkNotNull(urls, "urls");
+ this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ FormatType format;
+ try {
+ format = FormatType.getFormatType(req);
+ } catch (IllegalArgumentException err) {
+ res.sendError(SC_BAD_REQUEST);
+ return;
+ }
+
+ Set<String> branches = parseShowBranch(req);
+ Map<String, RepositoryDescription> descs;
+ try {
+ descs = accessFactory.forRequest(req).listRepositories(branches);
+ } catch (RepositoryNotFoundException e) {
+ res.sendError(SC_NOT_FOUND);
+ return;
+ } catch (ServiceNotEnabledException e) {
+ res.sendError(SC_FORBIDDEN);
+ return;
+ } catch (ServiceNotAuthorizedException e) {
+ res.sendError(SC_UNAUTHORIZED);
+ return;
+ } catch (ServiceMayNotContinueException e) {
+ // TODO(dborowitz): Show the error message to the user.
+ res.sendError(SC_FORBIDDEN);
+ return;
+ } catch (IOException err) {
+ String name = urls.getHostName(req);
+ log.warn("Cannot scan repositories" + (name != null ? "for " + name : ""), err);
+ res.sendError(SC_SERVICE_UNAVAILABLE);
+ return;
+ }
+
+ switch (format) {
+ case HTML:
+ case DEFAULT:
+ default:
+ displayHtml(req, res, descs);
+ break;
+
+ case TEXT:
+ displayText(req, res, branches, descs);
+ break;
+
+ case JSON:
+ displayJson(req, res, descs);
+ break;
+ }
+ }
+
+ private SoyMapData toSoyMapData(RepositoryDescription desc, GitilesView view) {
+ return new SoyMapData(
+ "name", desc.name,
+ "description", Strings.nullToEmpty(desc.description),
+ "url", GitilesView.repositoryIndex()
+ .copyFrom(view)
+ .setRepositoryName(desc.name)
+ .toUrl());
+ }
+
+ private void displayHtml(HttpServletRequest req, HttpServletResponse res,
+ Map<String, RepositoryDescription> descs) throws IOException {
+ SoyListData repos = new SoyListData();
+ for (RepositoryDescription desc : descs.values()) {
+ repos.add(toSoyMapData(desc, ViewFilter.getView(req)));
+ }
+
+ render(req, res, "gitiles.hostIndex", ImmutableMap.of(
+ "hostName", urls.getHostName(req),
+ "baseUrl", urls.getBaseGitUrl(req),
+ "repositories", repos));
+ }
+
+ private void displayText(HttpServletRequest req, HttpServletResponse res,
+ Set<String> branches, Map<String, RepositoryDescription> descs) throws IOException {
+ res.setContentType(TEXT.getMimeType());
+ res.setCharacterEncoding("UTF-8");
+ res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment");
+ setNotCacheable(res);
+
+ PrintWriter writer = res.getWriter();
+ for (RepositoryDescription repo : descs.values()) {
+ for (String name : branches) {
+ String ref = repo.branches.get(name);
+ if (ref == null) {
+ // Print stub (forty '-' symbols)
+ ref = "----------------------------------------";
+ }
+ writer.print(ref);
+ writer.print(' ');
+ }
+ writer.print(GitilesUrls.NAME_ESCAPER.apply(repo.name));
+ writer.print('\n');
+ }
+ writer.flush();
+ writer.close();
+ }
+
+ private void displayJson(HttpServletRequest req, HttpServletResponse res,
+ Map<String, RepositoryDescription> descs) throws IOException {
+ res.setContentType(JSON.getMimeType());
+ res.setCharacterEncoding("UTF-8");
+ res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment");
+ setNotCacheable(res);
+
+ PrintWriter writer = res.getWriter();
+ new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .setPrettyPrinting()
+ .generateNonExecutableJson()
+ .create()
+ .toJson(descs,
+ new TypeToken<Map<String, RepositoryDescription>>() {}.getType(),
+ writer);
+ writer.print('\n');
+ writer.close();
+ }
+
+ private static Set<String> parseShowBranch(HttpServletRequest req) {
+ // Roughly match Gerrit Code Review's /projects/ API by supporting
+ // both show-branch and b as query parameters.
+ Set<String> branches = Sets.newLinkedHashSet();
+ String[] values = req.getParameterValues("show-branch");
+ if (values != null) {
+ branches.addAll(Arrays.asList(values));
+ }
+ values = req.getParameterValues("b");
+ if (values != null) {
+ branches.addAll(Arrays.asList(values));
+ }
+ return branches;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java b/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java
new file mode 100644
index 0000000..63b1eb8
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java
@@ -0,0 +1,128 @@
+// 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.base.Charsets;
+import com.google.common.collect.ImmutableMap;
+
+import org.apache.commons.lang3.StringEscapeUtils;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.patch.FileHeader.PatchType;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+/** Formats a unified format patch as UTF-8 encoded HTML. */
+final class HtmlDiffFormatter extends DiffFormatter {
+ private static final byte[] DIFF_BEGIN = "<pre class=\"diff-unified\">".getBytes(Charsets.UTF_8);
+ private static final byte[] DIFF_END = "</pre>".getBytes(Charsets.UTF_8);
+
+ private static final byte[] HUNK_BEGIN = "<span class=\"h\">".getBytes(Charsets.UTF_8);
+ private static final byte[] HUNK_END = "</span>".getBytes(Charsets.UTF_8);
+
+ private static final byte[] LINE_INSERT_BEGIN = "<span class=\"i\">".getBytes(Charsets.UTF_8);
+ private static final byte[] LINE_DELETE_BEGIN = "<span class=\"d\">".getBytes(Charsets.UTF_8);
+ private static final byte[] LINE_CHANGE_BEGIN = "<span class=\"c\">".getBytes(Charsets.UTF_8);
+ private static final byte[] LINE_END = "</span>\n".getBytes(Charsets.UTF_8);
+
+ private final Renderer renderer;
+ private int fileIndex;
+
+ HtmlDiffFormatter(Renderer renderer, OutputStream out) {
+ super(out);
+ this.renderer = checkNotNull(renderer, "renderer");
+ }
+
+ @Override
+ public void format(List<? extends DiffEntry> entries) throws IOException {
+ for (fileIndex = 0; fileIndex < entries.size(); fileIndex++) {
+ format(entries.get(fileIndex));
+ }
+ }
+
+ @Override
+ public void format(FileHeader hdr, RawText a, RawText b)
+ throws IOException {
+ int start = hdr.getStartOffset();
+ int end = hdr.getEndOffset();
+ if (!hdr.getHunks().isEmpty()) {
+ end = hdr.getHunks().get(0).getStartOffset();
+ }
+ renderHeader(RawParseUtils.decode(hdr.getBuffer(), start, end));
+
+ if (hdr.getPatchType() == PatchType.UNIFIED) {
+ getOutputStream().write(DIFF_BEGIN);
+ format(hdr.toEditList(), a, b);
+ getOutputStream().write(DIFF_END);
+ }
+ }
+
+ private void renderHeader(String header)
+ throws IOException {
+ int lf = header.indexOf('\n');
+ String first;
+ String rest;
+ if (0 <= lf) {
+ first = header.substring(0, lf);
+ rest = header.substring(lf + 1);
+ } else {
+ first = header;
+ rest = "";
+ }
+ getOutputStream().write(renderer.newRenderer("gitiles.diffHeader")
+ .setData(ImmutableMap.of("first", first, "rest", rest, "fileIndex", fileIndex))
+ .render()
+ .getBytes(Charsets.UTF_8));
+ }
+
+ @Override
+ protected void writeHunkHeader(int aStartLine, int aEndLine,
+ int bStartLine, int bEndLine) throws IOException {
+ getOutputStream().write(HUNK_BEGIN);
+ // TODO(sop): If hunk header starts including method names, escape it.
+ super.writeHunkHeader(aStartLine, aEndLine, bStartLine, bEndLine);
+ getOutputStream().write(HUNK_END);
+ }
+
+ @Override
+ protected void writeLine(char prefix, RawText text, int cur)
+ throws IOException {
+ // Manually render each line, rather than invoke a Soy template. This method
+ // can be called thousands of times in a single request. Avoid unnecessary
+ // overheads by formatting as-is.
+ OutputStream out = getOutputStream();
+ switch (prefix) {
+ case '+':
+ out.write(LINE_INSERT_BEGIN);
+ break;
+ case '-':
+ out.write(LINE_DELETE_BEGIN);
+ break;
+ case ' ':
+ default:
+ out.write(LINE_CHANGE_BEGIN);
+ break;
+ }
+ out.write(StringEscapeUtils.escapeHtml4(text.getString(cur)).getBytes(Charsets.UTF_8));
+ out.write(LINE_END);
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
new file mode 100644
index 0000000..735e6f5
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
@@ -0,0 +1,96 @@
+// 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.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Linkifier for blocks of text such as commit message descriptions. */
+public class Linkifier {
+ private static final Pattern LINK_PATTERN;
+
+ static {
+ // HTTP URL regex adapted from com.google.gwtexpui.safehtml.client.SafeHtml.
+ String part = "[a-zA-Z0-9$_.+!*',%;:@=?#/~<>-]";
+ String httpUrl = "https?://" +
+ part + "{2,}" +
+ "(?:[(]" + part + "*" + "[)])*" +
+ part + "*";
+ String changeId = "\\bI[0-9a-f]{8,40}\\b";
+ LINK_PATTERN = Pattern.compile(Joiner.on("|").join(
+ "(" + httpUrl + ")",
+ "(" + changeId + ")"));
+ }
+
+ private final GitilesUrls urls;
+
+ public Linkifier(GitilesUrls urls) {
+ this.urls = checkNotNull(urls, "urls");
+ }
+
+ public List<Map<String, String>> linkify(HttpServletRequest req, String message) {
+ String baseGerritUrl = urls.getBaseGerritUrl(req);
+ List<Map<String, String>> parsed = Lists.newArrayList();
+ Matcher m = LINK_PATTERN.matcher(message);
+ int last = 0;
+ while (m.find()) {
+ addText(parsed, message.substring(last, m.start()));
+ if (m.group(1) != null) {
+ // Bare URL.
+ parsed.add(link(m.group(1), m.group(1)));
+ } else if (m.group(2) != null) {
+ if (baseGerritUrl != null) {
+ // Gerrit Change-Id.
+ parsed.add(link(m.group(2), baseGerritUrl + "#/q/" + m.group(2) + ",n,z"));
+ } else {
+ addText(parsed, m.group(2));
+ }
+ }
+ last = m.end();
+ }
+ addText(parsed, message.substring(last));
+ return parsed;
+ }
+
+ private static Map<String, String> link(String text, String url) {
+ return ImmutableMap.of("text", text, "url", url);
+ }
+
+ private static void addText(List<Map<String, String>> parts, String text) {
+ if (text.isEmpty()) {
+ return;
+ }
+ if (parts.isEmpty()) {
+ parts.add(ImmutableMap.of("text", text));
+ } else {
+ Map<String, String> old = parts.get(parts.size() - 1);
+ if (!old.containsKey("url")) {
+ parts.set(parts.size() - 1, ImmutableMap.of("text", old.get("text") + text));
+ } else {
+ parts.add(ImmutableMap.of("text", text));
+ }
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
new file mode 100644
index 0000000..07977f7
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
@@ -0,0 +1,193 @@
+// 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.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gitiles.CommitSoyData.KeySet;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RevWalkException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FollowFilter;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with a shortlog for commits and paths. */
+public class LogServlet extends BaseServlet {
+ private static final Logger log = LoggerFactory.getLogger(LogServlet.class);
+
+ private static final String START_PARAM = "s";
+
+ private final Linkifier linkifier;
+ private final int limit;
+
+ public LogServlet(Renderer renderer, Linkifier linkifier) {
+ this(renderer, linkifier, 100);
+ }
+
+ private LogServlet(Renderer renderer, Linkifier linkifier, int limit) {
+ super(renderer);
+ this.linkifier = checkNotNull(linkifier, "linkifier");
+ checkArgument(limit >= 0, "limit must be positive: %s", limit);
+ this.limit = limit;
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ Repository repo = ServletUtils.getRepository(req);
+ RevWalk walk = null;
+ try {
+ try {
+ walk = newWalk(repo, view);
+ } catch (IncorrectObjectTypeException e) {
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+
+ Optional<ObjectId> start = getStart(view.getParameters(), walk.getObjectReader());
+ if (start == null) {
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+
+ Map<String, Object> data = Maps.newHashMapWithExpectedSize(5);
+
+ if (!view.getRevision().nameIsId()) {
+ List<Map<String, Object>> tags = Lists.newArrayListWithExpectedSize(1);
+ for (RevObject o : RevisionServlet.listObjects(walk, view.getRevision().getId())) {
+ if (o instanceof RevTag) {
+ tags.add(new TagSoyData(linkifier, req).toSoyData((RevTag) o));
+ }
+ }
+ if (!tags.isEmpty()) {
+ data.put("tags", tags);
+ }
+ }
+
+ Paginator paginator = new Paginator(walk, limit, start.orNull());
+ Map<AnyObjectId, Set<Ref>> refsById = repo.getAllRefsByPeeledObjectId();
+ List<Map<String, Object>> entries = Lists.newArrayListWithCapacity(limit);
+ for (RevCommit c : paginator) {
+ entries.add(new CommitSoyData(null, req, repo, walk, view, refsById)
+ .toSoyData(c, KeySet.SHORTLOG));
+ }
+
+ String title = "Log - ";
+ if (view.getOldRevision() != Revision.NULL) {
+ title += view.getRevisionRange();
+ } else {
+ title += view.getRevision().getName();
+ }
+
+ data.put("title", title);
+ data.put("entries", entries);
+ ObjectId next = paginator.getNextStart();
+ if (next != null) {
+ data.put("nextUrl", copyAndCanonicalize(view)
+ .replaceParam(START_PARAM, next.name())
+ .toUrl());
+ }
+ ObjectId prev = paginator.getPreviousStart();
+ if (prev != null) {
+ GitilesView.Builder prevView = copyAndCanonicalize(view);
+ if (!prevView.getRevision().getId().equals(prev)) {
+ prevView.replaceParam(START_PARAM, prev.name());
+ }
+ data.put("previousUrl", prevView.toUrl());
+ }
+
+ render(req, res, "gitiles.logDetail", data);
+ } catch (RevWalkException e) {
+ log.warn("Error in rev walk", e);
+ res.setStatus(SC_INTERNAL_SERVER_ERROR);
+ return;
+ } finally {
+ if (walk != null) {
+ walk.release();
+ }
+ }
+ }
+
+ private static GitilesView.Builder copyAndCanonicalize(GitilesView view) {
+ // Canonicalize the view by using full SHAs.
+ GitilesView.Builder copy = GitilesView.log().copyFrom(view)
+ .setRevision(view.getRevision());
+ if (view.getOldRevision() != Revision.NULL) {
+ copy.setOldRevision(view.getOldRevision());
+ }
+ return copy;
+ }
+
+ private static Optional<ObjectId> getStart(ListMultimap<String, String> params,
+ ObjectReader reader) throws IOException {
+ List<String> values = params.get(START_PARAM);
+ switch (values.size()) {
+ case 0:
+ return Optional.absent();
+ case 1:
+ Collection<ObjectId> ids = reader.resolve(AbbreviatedObjectId.fromString(values.get(0)));
+ if (ids.size() != 1) {
+ return null;
+ }
+ return Optional.of(Iterables.getOnlyElement(ids));
+ default:
+ return null;
+ }
+ }
+
+ private static RevWalk newWalk(Repository repo, GitilesView view)
+ throws MissingObjectException, IncorrectObjectTypeException, IOException {
+ RevWalk walk = new RevWalk(repo);
+ walk.markStart(walk.parseCommit(view.getRevision().getId()));
+ if (view.getOldRevision() != Revision.NULL) {
+ walk.markUninteresting(walk.parseCommit(view.getOldRevision().getId()));
+ }
+ if (!Strings.isNullOrEmpty(view.getTreePath())) {
+ walk.setTreeFilter(FollowFilter.create(view.getTreePath()));
+ }
+ return walk;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
new file mode 100644
index 0000000..0454556
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
@@ -0,0 +1,176 @@
+// 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.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RevWalkException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+
+import javax.annotation.Nullable;
+
+/**
+ * Wrapper around {@link RevWalk} that paginates for Gitiles.
+ *
+ * A single page of a shortlog is defined by a revision range, such as "master"
+ * or "master..next", a page size, and a start commit, such as "c0ffee". The
+ * distance between the first commit in the walk ("next") and the first commit
+ * in the page may be arbitrarily long, but in order to present the commit list
+ * in a stable way, we must always start from the first commit in the walk. This
+ * is because there may be arbitrary merge commits between "c0ffee" and "next"
+ * that effectively insert arbitrary commits into the history starting from
+ * "c0ffee".
+ */
+class Paginator implements Iterable<RevCommit> {
+ private final RevWalk walk;
+ private final ObjectId start;
+ private final int limit;
+ private final Deque<ObjectId> prevBuffer;
+
+ private boolean done;
+ private int i;
+ private int n;
+ private int foundIndex;
+ private ObjectId nextStart;
+
+ /**
+ * @param walk revision walk.
+ * @param limit page size.
+ * @param start commit at which to start the walk, or null to start at the
+ * beginning.
+ */
+ Paginator(RevWalk walk, int limit, @Nullable ObjectId start) {
+ this.walk = checkNotNull(walk, "walk");
+ this.start = start;
+ checkArgument(limit > 0, "limit must be positive: %s", limit);
+ this.limit = limit;
+ prevBuffer = new ArrayDeque<ObjectId>(start != null ? limit : 0);
+ i = -1;
+ foundIndex = -1;
+ }
+
+ /**
+ * Get the next element in this page of the walk.
+ *
+ * @return the next element, or null if the walk is finished.
+ *
+ * @throws MissingObjectException See {@link RevWalk#next()}.
+ * @throws IncorrectObjectTypeException See {@link RevWalk#next()}.
+ * @throws IOException See {@link RevWalk#next()}.
+ */
+ public RevCommit next() throws MissingObjectException, IncorrectObjectTypeException,
+ IOException {
+ RevCommit commit;
+ if (foundIndex < 0) {
+ while (true) {
+ commit = walk.next();
+ if (commit == null) {
+ done = true;
+ return null;
+ }
+ i++;
+ if (start == null || start.equals(commit)) {
+ foundIndex = i;
+ break;
+ }
+ if (prevBuffer.size() == limit) {
+ prevBuffer.remove();
+ }
+ prevBuffer.add(commit);
+ }
+ } else {
+ commit = walk.next();
+ }
+
+ if (++n == limit) {
+ done = true;
+ } else if (n == limit + 1 || commit == null) {
+ nextStart = commit;
+ done = true;
+ return null;
+ }
+ return commit;
+ }
+
+ /**
+ * @return the ID at the start of the page of results preceding this one, or
+ * null if this is the first page.
+ */
+ public ObjectId getPreviousStart() {
+ checkState(done, "getPreviousStart() invalid before walk done");
+ return prevBuffer.pollFirst();
+ }
+
+ /**
+ * @return the ID at the start of the page of results after this one, or null
+ * if this is the last page.
+ */
+ public ObjectId getNextStart() {
+ checkState(done, "getNextStart() invalid before walk done");
+ return nextStart;
+ }
+
+ /**
+ * @return an iterator over the commits in this walk.
+ * @throws RevWalkException if an error occurred, wrapping the checked
+ * exception from {@link #next()}.
+ */
+ @Override
+ public Iterator<RevCommit> iterator() {
+ return new Iterator<RevCommit>() {
+ RevCommit next = nextUnchecked();
+
+ @Override
+ public boolean hasNext() {
+ return next != null;
+ }
+
+ @Override
+ public RevCommit next() {
+ RevCommit r = next;
+ next = nextUnchecked();
+ return r;
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+
+ private RevCommit nextUnchecked() {
+ try {
+ return next();
+ } catch (MissingObjectException e) {
+ throw new RevWalkException(e);
+ } catch (IncorrectObjectTypeException e) {
+ throw new RevWalkException(e);
+ } catch (IOException e) {
+ throw new RevWalkException(e);
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
new file mode 100644
index 0000000..d228202
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -0,0 +1,276 @@
+// 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.gitiles.TreeSoyData.resolveTargetUrl;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.submodule.SubmoduleWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with detailed information about a path within a tree. */
+// TODO(dborowitz): Handle non-UTF-8 names.
+public class PathServlet extends BaseServlet {
+ private static final Logger log = LoggerFactory.getLogger(PathServlet.class);
+
+ /**
+ * Submodule URLs where we know there is a web page if the user visits the
+ * repository URL verbatim in a web browser.
+ */
+ private static final Pattern VERBATIM_SUBMODULE_URL_PATTERN =
+ Pattern.compile("^(" + Joiner.on('|').join(
+ "https?://[^.]+.googlesource.com/.*",
+ "https?://[^.]+.googlecode.com/.*",
+ "https?://code.google.com/p/.*",
+ "https?://github.com/.*") + ")$", Pattern.CASE_INSENSITIVE);
+
+ static enum FileType {
+ TREE(FileMode.TREE),
+ SYMLINK(FileMode.SYMLINK),
+ REGULAR_FILE(FileMode.REGULAR_FILE),
+ EXECUTABLE_FILE(FileMode.EXECUTABLE_FILE),
+ GITLINK(FileMode.GITLINK);
+
+ private final FileMode mode;
+
+ private FileType(FileMode mode) {
+ this.mode = mode;
+ }
+
+ static FileType forEntry(TreeWalk tw) {
+ int mode = tw.getRawMode(0);
+ for (FileType type : values()) {
+ if (type.mode.equals(mode)) {
+ return type;
+ }
+ }
+ return null;
+ }
+ }
+
+ public PathServlet(Renderer renderer) {
+ super(renderer);
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ Repository repo = ServletUtils.getRepository(req);
+
+ RevWalk rw = new RevWalk(repo);
+ try {
+ RevObject obj = rw.peel(rw.parseAny(view.getRevision().getId()));
+ RevTree root;
+
+ switch (obj.getType()) {
+ case OBJ_COMMIT:
+ root = ((RevCommit) obj).getTree();
+ break;
+ case OBJ_TREE:
+ root = (RevTree) obj;
+ break;
+ default:
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+
+ TreeWalk tw;
+ FileType type;
+ String path = view.getTreePath();
+ if (path.isEmpty()) {
+ tw = new TreeWalk(rw.getObjectReader());
+ tw.addTree(root);
+ tw.setRecursive(false);
+ type = FileType.TREE;
+ } else {
+ tw = TreeWalk.forPath(rw.getObjectReader(), path, root);
+ if (tw == null) {
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+ type = FileType.forEntry(tw);
+ if (type == FileType.TREE) {
+ tw.enterSubtree();
+ tw.setRecursive(false);
+ }
+ }
+
+ switch (type) {
+ case TREE:
+ showTree(req, res, rw, tw, obj);
+ break;
+ case SYMLINK:
+ showSymlink(req, res, rw, tw);
+ break;
+ case REGULAR_FILE:
+ case EXECUTABLE_FILE:
+ showFile(req, res, rw, tw);
+ break;
+ case GITLINK:
+ showGitlink(req, res, rw, tw, root);
+ break;
+ default:
+ log.error("Bad file type: %s", type);
+ res.setStatus(SC_NOT_FOUND);
+ break;
+ }
+ } catch (LargeObjectException e) {
+ res.setStatus(SC_INTERNAL_SERVER_ERROR);
+ } finally {
+ rw.release();
+ }
+ }
+
+ private void showTree(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw,
+ ObjectId id) throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ // TODO(sop): Allow caching trees by SHA-1 when no S cookie is sent.
+ render(req, res, "gitiles.pathDetail", ImmutableMap.of(
+ "title", !view.getTreePath().isEmpty() ? view.getTreePath() : "/",
+ "type", FileType.TREE.toString(),
+ "data", new TreeSoyData(rw, view).toSoyData(id, tw)));
+ }
+
+ private void showFile(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw)
+ throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent.
+ render(req, res, "gitiles.pathDetail", ImmutableMap.of(
+ "title", ViewFilter.getView(req).getTreePath(),
+ "type", FileType.forEntry(tw).toString(),
+ "data", new BlobSoyData(rw, view).toSoyData(tw.getPathString(), tw.getObjectId(0))));
+ }
+
+ private void showSymlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw,
+ TreeWalk tw) throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ ObjectId id = tw.getObjectId(0);
+ Map<String, Object> data = Maps.newHashMap();
+
+ ObjectLoader loader = rw.getObjectReader().open(id, OBJ_BLOB);
+ String target;
+ try {
+ target = RawParseUtils.decode(loader.getCachedBytes(TreeSoyData.MAX_SYMLINK_SIZE));
+ } catch (LargeObjectException.OutOfMemory e) {
+ throw e;
+ } catch (LargeObjectException e) {
+ data.put("sha", ObjectId.toString(id));
+ data.put("data", null);
+ data.put("size", Long.toString(loader.getSize()));
+ render(req, res, "gitiles.pathDetail", ImmutableMap.of(
+ "title", ViewFilter.getView(req).getTreePath(),
+ "type", FileType.REGULAR_FILE.toString(),
+ "data", data));
+ return;
+ }
+
+ String url = resolveTargetUrl(
+ GitilesView.path()
+ .copyFrom(view)
+ .setTreePath(dirname(view.getTreePath()))
+ .build(),
+ target);
+ data.put("title", view.getTreePath());
+ data.put("target", target);
+ if (url != null) {
+ data.put("targetUrl", url);
+ }
+
+ // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent.
+ render(req, res, "gitiles.pathDetail", ImmutableMap.of(
+ "title", ViewFilter.getView(req).getTreePath(),
+ "type", FileType.SYMLINK.toString(),
+ "data", data));
+ }
+
+ private static String dirname(String path) {
+ while (path.charAt(path.length() - 1) == '/') {
+ path = path.substring(0, path.length() - 1);
+ }
+ int lastSlash = path.lastIndexOf('/');
+ if (lastSlash > 0) {
+ return path.substring(0, lastSlash - 1);
+ } else if (lastSlash == 0) {
+ return "/";
+ } else {
+ return ".";
+ }
+ }
+
+ private void showGitlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw,
+ TreeWalk tw, RevTree root) throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ SubmoduleWalk sw = SubmoduleWalk.forPath(ServletUtils.getRepository(req), root,
+ view.getTreePath());
+
+ String remoteUrl;
+ try {
+ remoteUrl = sw.getRemoteUrl();
+ } catch (ConfigInvalidException e) {
+ throw new IOException(e);
+ } finally {
+ sw.release();
+ }
+
+ Map<String, Object> data = Maps.newHashMap();
+ data.put("sha", ObjectId.toString(tw.getObjectId(0)));
+ data.put("remoteUrl", remoteUrl);
+
+ // TODO(dborowitz): Guess when we can put commit SHAs in the URL.
+ String httpUrl = resolveHttpUrl(remoteUrl);
+ if (httpUrl != null) {
+ data.put("httpUrl", httpUrl);
+ }
+
+ // TODO(sop): Allow caching links by SHA-1 when no S cookie is sent.
+ render(req, res, "gitiles.pathDetail", ImmutableMap.of(
+ "title", view.getTreePath(),
+ "type", FileType.GITLINK.toString(),
+ "data", data));
+ }
+
+ private static String resolveHttpUrl(String remoteUrl) {
+ return VERBATIM_SUBMODULE_URL_PATTERN.matcher(remoteUrl).matches() ? remoteUrl : null;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
new file mode 100644
index 0000000..42ce635
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
@@ -0,0 +1,107 @@
+// 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.base.Charsets;
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.template.soy.tofu.SoyTofu;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletResponse;
+
+/** Renderer for Soy templates used by Gitiles. */
+public abstract class Renderer {
+ private static final List<String> SOY_FILENAMES = ImmutableList.of(
+ "Common.soy",
+ "DiffDetail.soy",
+ "HostIndex.soy",
+ "LogDetail.soy",
+ "ObjectDetail.soy",
+ "PathDetail.soy",
+ "RevisionDetail.soy",
+ "RepositoryIndex.soy");
+
+ public static final Map<String, String> STATIC_URL_GLOBALS = ImmutableMap.of(
+ "gitiles.CSS_URL", "gitiles.css",
+ "gitiles.PRETTIFY_CSS_URL", "prettify/prettify.css",
+ "gitiles.PRETTIFY_JS_URL", "prettify/prettify.js");
+
+ protected static final URL toFileURL(String filename) {
+ if (filename == null) {
+ return null;
+ }
+ try {
+ return new File(filename).toURI().toURL();
+ } catch (MalformedURLException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ protected ImmutableList<URL> templates;
+ protected ImmutableMap<String, String> globals;
+
+ protected Renderer(Function<String, URL> resourceMapper, Map<String, String> globals,
+ String staticPrefix, URL customTemplates) {
+ checkNotNull(staticPrefix, "staticPrefix");
+ List<URL> allTemplates = Lists.newArrayListWithCapacity(SOY_FILENAMES.size() + 1);
+ for (String filename : SOY_FILENAMES) {
+ allTemplates.add(resourceMapper.apply(filename));
+ }
+ if (customTemplates != null) {
+ allTemplates.add(customTemplates);
+ } else {
+ allTemplates.add(resourceMapper.apply("DefaultCustomTemplates.soy"));
+ }
+ templates = ImmutableList.copyOf(allTemplates);
+
+ Map<String, String> allGlobals = Maps.newHashMap();
+ for (Map.Entry<String, String> e : STATIC_URL_GLOBALS.entrySet()) {
+ allGlobals.put(e.getKey(), staticPrefix + e.getValue());
+ }
+ allGlobals.putAll(globals);
+ this.globals = ImmutableMap.copyOf(allGlobals);
+ }
+
+ public void render(HttpServletResponse res, String templateName) throws IOException {
+ render(res, templateName, ImmutableMap.<String, Object> of());
+ }
+
+ public void render(HttpServletResponse res, String templateName, Map<String, ?> soyData)
+ throws IOException {
+ res.setContentType("text/html");
+ res.setCharacterEncoding("UTF-8");
+ byte[] data = newRenderer(templateName).setData(soyData).render().getBytes(Charsets.UTF_8);
+ res.setContentLength(data.length);
+ res.getOutputStream().write(data);
+ }
+
+ SoyTofu.Renderer newRenderer(String templateName) {
+ return getTofu().newRenderer(templateName);
+ }
+
+ protected abstract SoyTofu getTofu();
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java
new file mode 100644
index 0000000..06ee4da
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java
@@ -0,0 +1,25 @@
+// 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 java.util.Map;
+
+/** Describes a repository in the {@link HostIndexServlet} JSON output. */
+public class RepositoryDescription {
+ public String name;
+ public String cloneUrl;
+ public String description;
+ public Map<String, String> branches;
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
new file mode 100644
index 0000000..325068a
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -0,0 +1,80 @@
+// 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.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefComparator;
+import org.eclipse.jgit.lib.RefDatabase;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves the index page for a repository, if accessed directly by a browser. */
+public class RepositoryIndexServlet extends BaseServlet {
+ private final GitilesAccess.Factory accessFactory;
+
+ public RepositoryIndexServlet(Renderer renderer, GitilesAccess.Factory accessFactory) {
+ super(renderer);
+ this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ render(req, res, "gitiles.repositoryIndex", buildData(req));
+ }
+
+ @VisibleForTesting
+ Map<String, ?> buildData(HttpServletRequest req) throws IOException {
+ RepositoryDescription desc = accessFactory.forRequest(req).getRepositoryDescription();
+ return ImmutableMap.of(
+ "cloneUrl", desc.cloneUrl,
+ "description", Strings.nullToEmpty(desc.description),
+ "branches", getRefs(req, Constants.R_HEADS),
+ "tags", getRefs(req, Constants.R_TAGS));
+ }
+
+ private List<Map<String, String>> getRefs(HttpServletRequest req, String prefix)
+ throws IOException {
+ RefDatabase refdb = ServletUtils.getRepository(req).getRefDatabase();
+ String repoName = ViewFilter.getView(req).getRepositoryName();
+ Collection<Ref> refs = RefComparator.sort(refdb.getRefs(prefix).values());
+ List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size());
+
+ for (Ref ref : refs) {
+ String name = ref.getName().substring(prefix.length());
+ boolean needPrefix = !ref.getName().equals(refdb.getRef(name).getName());
+ result.add(ImmutableMap.of(
+ "url", GitilesView.log().copyFrom(req).setRevision(
+ Revision.unpeeled(needPrefix ? ref.getName() : name, ref.getObjectId())).toUrl(),
+ "name", name));
+ }
+
+ return result;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java b/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java
new file mode 100644
index 0000000..05778e0
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java
@@ -0,0 +1,138 @@
+// 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.checkArgument;
+import static org.eclipse.jgit.lib.Constants.OBJ_BAD;
+import static org.eclipse.jgit.lib.Constants.OBJ_TAG;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Objects;
+
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+
+/**
+ * Object encapsulating a single revision as seen by Gitiles.
+ * <p>
+ * A single revision consists of a name, an ID, and a type. Name parsing is done
+ * once per request by {@link RevisionParser}.
+ */
+public class Revision {
+ /** Sentinel indicating a missing or empty revision. */
+ public static final Revision NULL = peeled("", ObjectId.zeroId(), OBJ_BAD);
+
+ /** Common default branch given to clients. */
+ public static final Revision HEAD = named("HEAD");
+
+ private final String name;
+ private final ObjectId id;
+ private final int type;
+ private final ObjectId peeledId;
+ private final int peeledType;
+
+ public static Revision peeled(String name, RevObject obj) {
+ return peeled(name, obj, obj.getType());
+ }
+
+ public static Revision unpeeled(String name, ObjectId id) {
+ return peeled(name, id, OBJ_BAD);
+ }
+
+ public static Revision named(String name) {
+ return peeled(name, null, OBJ_BAD);
+ }
+
+ public static Revision peel(String name, ObjectId id, RevWalk walk)
+ throws MissingObjectException, IOException {
+ RevObject obj = walk.parseAny(id);
+ RevObject peeled = walk.peel(obj);
+ return new Revision(name, obj, obj.getType(), peeled, peeled.getType());
+ }
+
+ private static Revision peeled(String name, ObjectId id, int type) {
+ checkArgument(type != OBJ_TAG, "expected non-tag for %s/%s", name, id);
+ return new Revision(name, id, type, id, type);
+ }
+
+ @VisibleForTesting
+ Revision(String name, ObjectId id, int type, ObjectId peeledId, int peeledType) {
+ this.name = name;
+ this.id = id;
+ this.type = type;
+ this.peeledId = peeledId;
+ this.peeledType = peeledType;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getType() {
+ return type;
+ }
+
+ public ObjectId getId() {
+ return id;
+ }
+
+ public ObjectId getPeeledId() {
+ return peeledId;
+ }
+
+ public int getPeeledType() {
+ return peeledType;
+ }
+
+ public boolean nameIsId() {
+ return AbbreviatedObjectId.isId(name)
+ && (AbbreviatedObjectId.fromString(name).prefixCompare(id) == 0);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof Revision) {
+ Revision r = (Revision) o;
+ return Objects.equal(name, r.name)
+ && Objects.equal(id, r.id)
+ && Objects.equal(type, r.type)
+ && Objects.equal(peeledId, r.peeledId)
+ && Objects.equal(peeledType, r.peeledType);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(name, id, type, peeledId, peeledType);
+ }
+
+ @Override
+ public String toString() {
+ return Objects.toStringHelper(this)
+ .omitNullValues()
+ .add("name", name)
+ .add("id", id)
+ .add("type", type)
+ .add("peeledId", peeledId)
+ .add("peeledType", peeledType)
+ .toString();
+ }
+}
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;
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
new file mode 100644
index 0000000..66a7086
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
@@ -0,0 +1,136 @@
+// 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 static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+import static org.eclipse.jgit.lib.Constants.OBJ_TAG;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gitiles.CommitSoyData.KeySet;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with detailed information about a ref. */
+public class RevisionServlet extends BaseServlet {
+ private static final Logger log = LoggerFactory.getLogger(RevisionServlet.class);
+
+ private final Linkifier linkifier;
+
+ public RevisionServlet(Renderer renderer, Linkifier linkifier) {
+ super(renderer);
+ this.linkifier = checkNotNull(linkifier, "linkifier");
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ GitilesView view = ViewFilter.getView(req);
+ Repository repo = ServletUtils.getRepository(req);
+
+ RevWalk walk = new RevWalk(repo);
+ try {
+ List<RevObject> objects = listObjects(walk, view.getRevision().getId());
+ List<Map<String, ?>> soyObjects = Lists.newArrayListWithCapacity(objects.size());
+ boolean hasBlob = false;
+
+ // TODO(sop): Allow caching commits by SHA-1 when no S cookie is sent.
+ for (RevObject obj : objects) {
+ try {
+ switch (obj.getType()) {
+ case OBJ_COMMIT:
+ soyObjects.add(ImmutableMap.of(
+ "type", Constants.TYPE_COMMIT,
+ "data", new CommitSoyData(linkifier, req, repo, walk, view)
+ .toSoyData((RevCommit) obj, KeySet.DETAIL_DIFF_TREE)));
+ break;
+ case OBJ_TREE:
+ soyObjects.add(ImmutableMap.of(
+ "type", Constants.TYPE_TREE,
+ "data", new TreeSoyData(walk, view).toSoyData(obj)));
+ break;
+ case OBJ_BLOB:
+ soyObjects.add(ImmutableMap.of(
+ "type", Constants.TYPE_BLOB,
+ "data", new BlobSoyData(walk, view).toSoyData(obj)));
+ hasBlob = true;
+ break;
+ case OBJ_TAG:
+ soyObjects.add(ImmutableMap.of(
+ "type", Constants.TYPE_TAG,
+ "data", new TagSoyData(linkifier, req).toSoyData((RevTag) obj)));
+ break;
+ default:
+ log.warn("Bad object type for %s: %s", ObjectId.toString(obj.getId()), obj.getType());
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+ } catch (MissingObjectException e) {
+ log.warn("Missing object " + ObjectId.toString(obj.getId()), e);
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ } catch (IncorrectObjectTypeException e) {
+ log.warn("Incorrect object type for " + ObjectId.toString(obj.getId()), e);
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+ }
+
+ render(req, res, "gitiles.revisionDetail", ImmutableMap.of(
+ "title", view.getRevision().getName(),
+ "objects", soyObjects,
+ "hasBlob", hasBlob));
+ } finally {
+ walk.release();
+ }
+ }
+
+ // TODO(dborowitz): Extract this.
+ static List<RevObject> listObjects(RevWalk walk, ObjectId id)
+ throws MissingObjectException, IOException {
+ List<RevObject> objects = Lists.newArrayListWithExpectedSize(1);
+ while (true) {
+ RevObject cur = walk.parseAny(id);
+ objects.add(cur);
+ if (cur.getType() == Constants.OBJ_TAG) {
+ id = ((RevTag) cur).getObject();
+ } else {
+ break;
+ }
+ }
+ return objects;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java
new file mode 100644
index 0000000..9ddf1e5
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java
@@ -0,0 +1,50 @@
+// 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 com.google.common.collect.Maps;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.util.GitDateFormatter;
+import org.eclipse.jgit.util.GitDateFormatter.Format;
+
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Soy data converter for git tags. */
+public class TagSoyData {
+ private final Linkifier linkifier;
+ private final HttpServletRequest req;
+ private final GitDateFormatter dateFormatter;
+
+ public TagSoyData(Linkifier linkifier, HttpServletRequest req) {
+ this.linkifier = linkifier;
+ this.req = req;
+ this.dateFormatter = new GitDateFormatter(Format.DEFAULT);
+ }
+
+ public Map<String, Object> toSoyData(RevTag tag) {
+ Map<String, Object> data = Maps.newHashMapWithExpectedSize(4);
+ data.put("sha", ObjectId.toString(tag));
+ if (tag.getTaggerIdent() != null) {
+ data.put("tagger", CommitSoyData.toSoyData(tag.getTaggerIdent(), dateFormatter));
+ }
+ data.put("object", ObjectId.toString(tag.getObject()));
+ data.put("message", linkifier.linkify(req, tag.getFullMessage()));
+ return data;
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
new file mode 100644
index 0000000..6cbf541
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -0,0 +1,160 @@
+// 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.gitiles.RevisionParser.PATH_SPLITTER;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Charsets;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.io.Files;
+import com.google.gitiles.PathServlet.FileType;
+
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.StringTokenizer;
+
+/** Soy data converter for git trees. */
+public class TreeSoyData {
+ /**
+ * Number of characters to display for a symlink target. Targets longer than
+ * this are abbreviated for display in a tree listing.
+ */
+ private static final int MAX_SYMLINK_TARGET_LENGTH = 72;
+
+ /**
+ * Maximum number of bytes to load from a blob that claims to be a symlink. If
+ * the blob is larger than this byte limit it will be displayed as a binary
+ * file instead of as a symlink.
+ */
+ static final int MAX_SYMLINK_SIZE = 16 << 10;
+
+ static String resolveTargetUrl(GitilesView view, String target) {
+ if (target.startsWith("/")) {
+ return null;
+ }
+
+ // simplifyPath() normalizes "a/../../" to "a", so manually check whether
+ // the path leads above the git root.
+ int depth = new StringTokenizer(view.getTreePath(), "/").countTokens();
+ for (String part : PATH_SPLITTER.split(target)) {
+ if (part.equals("..")) {
+ depth--;
+ if (depth < 0) {
+ return null;
+ }
+ } else if (!part.isEmpty() && !part.equals(".")) {
+ depth++;
+ }
+ }
+
+ String path = Files.simplifyPath(view.getTreePath() + "/" + target);
+ return GitilesView.path()
+ .copyFrom(view)
+ .setTreePath(!path.equals(".") ? path : "")
+ .toUrl();
+ }
+
+ @VisibleForTesting
+ static String getTargetDisplayName(String target) {
+ if (target.length() <= MAX_SYMLINK_TARGET_LENGTH) {
+ return target;
+ } else {
+ int lastSlash = target.lastIndexOf('/');
+ // TODO(dborowitz): Doesn't abbreviate a long last path component.
+ return lastSlash >= 0 ? "..." + target.substring(lastSlash) : target;
+ }
+ }
+
+ private final RevWalk rw;
+ private final GitilesView view;
+
+ public TreeSoyData(RevWalk rw, GitilesView view) {
+ this.rw = rw;
+ this.view = view;
+ }
+
+ public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw) throws MissingObjectException,
+ IOException {
+ List<Object> entries = Lists.newArrayList();
+ GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view);
+ while (tw.next()) {
+ FileType type = FileType.forEntry(tw);
+ String name = tw.getNameString();
+
+ switch (view.getType()) {
+ case PATH:
+ urlBuilder.setTreePath(view.getTreePath() + "/" + name);
+ break;
+ case REVISION:
+ // Got here from a tag pointing at a tree.
+ urlBuilder.setTreePath(name);
+ break;
+ default:
+ throw new IllegalStateException(String.format(
+ "Cannot render TreeSoyData from %s view", view.getType()));
+ }
+
+ String url = urlBuilder.toUrl();
+ if (type == FileType.TREE) {
+ name += "/";
+ url += "/";
+ }
+ Map<String, String> entry = Maps.newHashMapWithExpectedSize(4);
+ entry.put("type", type.toString());
+ entry.put("name", name);
+ entry.put("url", url);
+ if (type == FileType.SYMLINK) {
+ String target = new String(
+ rw.getObjectReader().open(tw.getObjectId(0)).getCachedBytes(),
+ Charsets.UTF_8);
+ // TODO(dborowitz): Merge Shawn's changes before copying these methods
+ // in.
+ entry.put("targetName", getTargetDisplayName(target));
+ String targetUrl = resolveTargetUrl(view, target);
+ if (targetUrl != null) {
+ entry.put("targetUrl", targetUrl);
+ }
+ }
+ entries.add(entry);
+ }
+
+ Map<String, Object> data = Maps.newHashMapWithExpectedSize(3);
+ data.put("sha", treeId.name());
+ data.put("entries", entries);
+
+ if (view.getType() == GitilesView.Type.PATH
+ && view.getRevision().getPeeledType() == OBJ_COMMIT) {
+ data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
+ }
+
+ return data;
+ }
+
+ public Map<String, Object> toSoyData(ObjectId treeId) throws MissingObjectException, IOException {
+ TreeWalk tw = new TreeWalk(rw.getObjectReader());
+ tw.addTree(treeId);
+ tw.setRecursive(false);
+ return toSoyData(treeId, tw);
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
new file mode 100644
index 0000000..8874b1d
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -0,0 +1,160 @@
+// 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.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.http.server.glue.WrappedRequest;
+import org.eclipse.jgit.lib.Constants;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Filter to parse URLs and convert them to {@link GitilesView}s. */
+public class ViewFilter extends AbstractHttpFilter {
+ // TODO(dborowitz): Make this public in JGit (or implement getRegexGroup
+ // upstream).
+ private static final String REGEX_GROUPS_ATTRIBUTE =
+ "org.eclipse.jgit.http.server.glue.MetaServlet.serveRegex";
+
+ private static final String VIEW_ATTIRBUTE = ViewFilter.class.getName() + "/View";
+
+ private static final String CMD_AUTO = "+";
+ private static final String CMD_DIFF = "+diff";
+ private static final String CMD_LOG = "+log";
+ private static final String CMD_SHOW = "+show";
+
+ public static GitilesView getView(HttpServletRequest req) {
+ return (GitilesView) req.getAttribute(VIEW_ATTIRBUTE);
+ }
+
+ static String getRegexGroup(HttpServletRequest req, int groupId) {
+ WrappedRequest[] groups = (WrappedRequest[]) req.getAttribute(REGEX_GROUPS_ATTRIBUTE);
+ return checkNotNull(groups)[groupId].getPathInfo();
+ }
+
+ static void setView(HttpServletRequest req, GitilesView view) {
+ req.setAttribute(VIEW_ATTIRBUTE, view);
+ }
+
+ static String trimLeadingSlash(String str) {
+ checkArgument(str.startsWith("/"), "expected string starting with a slash: %s", str);
+ return str.substring(1);
+ }
+
+ private final GitilesUrls urls;
+ private final GitilesAccess.Factory accessFactory;
+ private final VisibilityCache visibilityCache;
+
+ public ViewFilter(GitilesAccess.Factory accessFactory, GitilesUrls urls,
+ VisibilityCache visibilityCache) {
+ this.urls = checkNotNull(urls, "urls");
+ this.accessFactory = checkNotNull(accessFactory, "accessFactory");
+ this.visibilityCache = checkNotNull(visibilityCache, "visibilityCache");
+ }
+
+ @Override
+ public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+ throws IOException, ServletException {
+ GitilesView.Builder view = parse(req);
+ if (view == null) {
+ res.setStatus(SC_NOT_FOUND);
+ return;
+ }
+ @SuppressWarnings("unchecked")
+ Map<String, String[]> params = req.getParameterMap();
+ view.setHostName(urls.getHostName(req))
+ .setServletPath(req.getContextPath() + req.getServletPath())
+ .putAllParams(params);
+ setView(req, view.build());
+ try {
+ chain.doFilter(req, res);
+ } finally {
+ req.removeAttribute(VIEW_ATTIRBUTE);
+ }
+ }
+
+ private GitilesView.Builder parse(HttpServletRequest req) throws IOException {
+ String repoName = trimLeadingSlash(getRegexGroup(req, 1));
+ String command = getRegexGroup(req, 2);
+ String path = getRegexGroup(req, 3);
+
+ // Non-path cases.
+ if (repoName.isEmpty()) {
+ return GitilesView.hostIndex();
+ } else if (command.isEmpty()) {
+ return GitilesView.repositoryIndex().setRepositoryName(repoName);
+ } else if (path.isEmpty()) {
+ return null; // Command but no path.
+ }
+
+ path = trimLeadingSlash(path);
+ RevisionParser revParser = new RevisionParser(
+ ServletUtils.getRepository(req), accessFactory.forRequest(req), visibilityCache);
+ RevisionParser.Result result = revParser.parse(path);
+ if (result == null) {
+ return null;
+ }
+ path = path.substring(result.getPathStart());
+
+ command = getCommand(command, result, path);
+ GitilesView.Builder view;
+ if (CMD_LOG.equals(command)) {
+ view = GitilesView.log().setTreePath(path);
+ } else if (CMD_SHOW.equals(command)) {
+ if (path.isEmpty()) {
+ view = GitilesView.revision();
+ } else {
+ view = GitilesView.path().setTreePath(path);
+ }
+ } else if (CMD_DIFF.equals(command)) {
+ view = GitilesView.diff().setTreePath(path);
+ } else {
+ return null; // Bad command.
+ }
+ if (result.getOldRevision() != null) { // May be NULL.
+ view.setOldRevision(result.getOldRevision());
+ }
+ view.setRepositoryName(repoName)
+ .setRevision(result.getRevision());
+ return view;
+ }
+
+ private String getCommand(String command, RevisionParser.Result result, String path) {
+ // Note: if you change the mapping for +, make sure to change
+ // GitilesView.toUrl() correspondingly.
+ if (!CMD_AUTO.equals(command)) {
+ return command;
+ } else if (result.getOldRevision() != null) {
+ return CMD_DIFF;
+ }
+ Revision rev = result.getRevision();
+ if (rev.getPeeledType() != Constants.OBJ_COMMIT
+ || !path.isEmpty()
+ || result.getRevision().nameIsId()) {
+ return CMD_SHOW;
+ } else {
+ return CMD_LOG;
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java b/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java
new file mode 100644
index 0000000..22969d1
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java
@@ -0,0 +1,189 @@
+// 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 static com.google.common.base.Predicates.not;
+import static com.google.common.collect.Collections2.filter;
+
+import com.google.common.base.Objects;
+import com.google.common.base.Predicate;
+import com.google.common.base.Throwables;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+/** Cache of per-user object visibility. */
+public class VisibilityCache {
+ private static class Key {
+ private final Object user;
+ private final String repositoryName;
+ private final ObjectId objectId;
+
+ private Key(Object user, String repositoryName, ObjectId objectId) {
+ this.user = checkNotNull(user, "user");
+ this.repositoryName = checkNotNull(repositoryName, "repositoryName");
+ this.objectId = checkNotNull(objectId, "objectId");
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof Key) {
+ Key k = (Key) o;
+ return Objects.equal(user, k.user)
+ && Objects.equal(repositoryName, k.repositoryName)
+ && Objects.equal(objectId, k.objectId);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(user, repositoryName, objectId);
+ }
+
+ @Override
+ public String toString() {
+ return Objects.toStringHelper(this)
+ .add("user", user)
+ .add("repositoryName", repositoryName)
+ .add("objectId", objectId)
+ .toString();
+ }
+ }
+
+ private final Cache<Key, Boolean> cache;
+ private final boolean topoSort;
+
+ public static CacheBuilder<Object, Object> newBuilder() {
+ return CacheBuilder.newBuilder()
+ .maximumSize(1 << 10)
+ .expireAfterWrite(30, TimeUnit.MINUTES);
+ }
+
+ public VisibilityCache(boolean topoSort) {
+ this(topoSort, newBuilder());
+ }
+
+ public VisibilityCache(boolean topoSort, CacheBuilder<Object, Object> builder) {
+ this.cache = builder.build();
+ this.topoSort = topoSort;
+ }
+
+ public Cache<?, Boolean> getCache() {
+ return cache;
+ }
+
+ boolean isVisible(final Repository repo, final RevWalk walk, GitilesAccess access,
+ final ObjectId id) throws IOException {
+ try {
+ return cache.get(
+ new Key(access.getUserKey(), access.getRepositoryName(), id),
+ new Callable<Boolean>() {
+ @Override
+ public Boolean call() throws IOException {
+ return isVisible(repo, walk, id);
+ }
+ });
+ } catch (ExecutionException e) {
+ Throwables.propagateIfInstanceOf(e.getCause(), IOException.class);
+ throw new IOException(e);
+ }
+ }
+
+ private boolean isVisible(Repository repo, RevWalk walk, ObjectId id) throws IOException {
+ RevCommit commit;
+ try {
+ commit = walk.parseCommit(id);
+ } catch (IncorrectObjectTypeException e) {
+ return false;
+ }
+
+ // If any reference directly points at the requested object, permit display.
+ // Common for displays of pending patch sets in Gerrit Code Review, or
+ // bookmarks to the commit a tag points at.
+ Collection<Ref> allRefs = repo.getRefDatabase().getRefs(RefDatabase.ALL).values();
+ for (Ref ref : allRefs) {
+ ref = repo.getRefDatabase().peel(ref);
+ if (id.equals(ref.getObjectId()) || id.equals(ref.getPeeledObjectId())) {
+ return true;
+ }
+ }
+
+ // Check heads first under the assumption that most requests are for refs
+ // close to a head. Tags tend to be much further back in history and just
+ // clutter up the priority queue in the common case.
+ return isReachableFrom(walk, commit, filter(allRefs, refStartsWith(Constants.R_HEADS)))
+ || isReachableFrom(walk, commit, filter(allRefs, refStartsWith(Constants.R_TAGS)))
+ || isReachableFrom(walk, commit, filter(allRefs, not(refStartsWith("refs/changes/"))));
+ }
+
+ private static Predicate<Ref> refStartsWith(final String prefix) {
+ return new Predicate<Ref>() {
+ @Override
+ public boolean apply(Ref ref) {
+ return ref.getName().startsWith(prefix);
+ }
+ };
+ }
+
+ private boolean isReachableFrom(RevWalk walk, RevCommit commit, Collection<Ref> refs)
+ throws IOException {
+ walk.reset();
+ if (topoSort) {
+ walk.sort(RevSort.TOPO);
+ }
+ walk.markStart(commit);
+ for (Ref ref : refs) {
+ if (ref.getPeeledObjectId() != null) {
+ markUninteresting(walk, ref.getPeeledObjectId());
+ } else {
+ markUninteresting(walk, ref.getObjectId());
+ }
+ }
+ // If the commit is reachable from any branch head, it will appear to be
+ // uninteresting to the RevWalk and no output will be produced.
+ return walk.next() == null;
+ }
+
+ private static void markUninteresting(RevWalk walk, ObjectId id) throws IOException {
+ if (id == null) {
+ return;
+ }
+ try {
+ walk.markUninteresting(walk.parseCommit(id));
+ } catch (IncorrectObjectTypeException e) {
+ // Do nothing, doesn't affect reachability.
+ } catch (MissingObjectException e) {
+ // Do nothing, doesn't affect reachability.
+ }
+ }
+}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
new file mode 100644
index 0000000..0c57519
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
@@ -0,0 +1,319 @@
+/**
+ * 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.
+ */
+
+/* Common styles and definitions. */
+
+h1 {
+ position: absolute;
+ top: 0;
+ white-space: nowrap;
+ margin-top: 5px;
+}
+.menu {
+ position: absolute;
+ top: 0;
+ right: 0;
+ font-size: 10pt;
+ white-space: nowrap;
+ text-align: right;
+ margin-top: 5px;
+ margin-right: 5px;
+}
+.menu .entry {
+ padding-right: 5px;
+ border-right: 1px solid black;
+ margin-right: 0;
+}
+h2 {
+ margin-top: 3em;
+}
+.breadcrumbs {
+ margin-top: 3em;
+ font-size: 150%;
+ border-bottom: #ddd solid 1px; /* BORDER */
+}
+table.list {
+ margin-top: 1em;
+ width: 90%;
+}
+table.list tr.no-hover:hover {
+ background: #fff;
+}
+table.list tr:hover, ol.list li:hover, pre.prettyprint li:hover {
+ background: #eee; /* HOVER_BACKGROUND */
+}
+table.list td {
+ white-space: nowrap;
+ padding-top: 0.25em;
+ padding-bottom: 0.25em;
+}
+.log-link {
+ margin-left: 0.5em
+}
+
+
+/* Styles for the host index page. */
+
+.instructions {
+ width: 45em;
+ margin-left: 1em;
+ margin-right: 1em;
+ border-top: 1px solid #555;
+ border-bottom: 1px solid #555;
+ color: #555;
+}
+.instructions pre {
+ display: block;
+ margin-left: 1em;
+ border-left: 2px solid #060;
+ padding-left: 1em;
+ white-space: nowrap;
+}
+.footer {
+ text-align: right;
+ background: #eee;
+ padding-right: 1em;
+ width: 90%;
+}
+.footer a {
+ color: black;
+ font-weight: bold;
+ font-size: 70%;
+}
+
+
+/* Styles for the repository index page. */
+
+.repository-description {
+ border-bottom: #ddd solid 1px; /* BORDER */
+ padding-bottom: 5px; /* VPADDING */
+}
+.repository-refs {
+ width: 450px;
+}
+.repository-branches {
+ float: left;
+ width: 200px;
+}
+.repository-tags {
+ float: right;
+ width: 250px;
+}
+.clone-line {
+ background-color: #e5ecf9; /* BOX_BACKGROUND */
+ border: none;
+ margin: 5px /* VPADDING */ 0 0 0;
+ padding: 5px 2em; /* PADDING */
+ font-size: 9pt;
+}
+
+
+/* Styles for the object detail templates. */
+
+.sha1 {
+ color: #666;
+ font-size: 9pt;
+}
+div.sha1 {
+ padding-top: 5px; /* VPADDING */
+}
+
+.git-commit, .git-tag {
+ font-size: 9pt;
+ border-bottom: #ddd solid 1px; /* BORDER */
+ padding: 5px 2em; /* PADDING */
+}
+.git-commit table, .git-tag table {
+ margin: 0;
+}
+.git-commit table th, .git-tag table th {
+ text-align: right;
+}
+pre.commit-message, pre.tag-message {
+ border-bottom: #ddd solid 1px; /* BORDER */
+ padding: 5px 2em; /* PADDING */
+ color: #000;
+ font-size: 9pt;
+ margin: 0;
+}
+
+ul.diff-tree {
+ font-size: 9pt;
+ list-style-type: none;
+ margin: 0;
+ padding: 5px 2em; /* PADDING */
+}
+ul.diff-tree .add {
+ color: #060;
+}
+ul.diff-tree .delete {
+ color: #600;
+}
+ul.diff-tree .rename, ul.diff-tree .copy {
+ color: #006;
+}
+span.diff-link, ul.diff-tree .add, ul.diff-tree .modify, ul.diff-tree .delete,
+ ul.diff-tree .rename, ul.diff-tree .copy {
+ margin-left: 0.5em;
+}
+.diff-summary {
+ font-size: 9pt;
+ font-style: italic;
+ padding: 5px 2em; /* PADDING */
+ border-bottom: #ddd solid 1px; /* BORDER */
+}
+
+ol.files {
+ list-style-type: none;
+ margin-left: 1em;
+ font-size: 10pt;
+ line-height: normal;
+}
+
+/* Tree icons are taken from the public domain Apache standard icons:
+ * http://www.apache.org/icons/ */
+ol.files li.git-tree{
+ /* small/folder.png */
+ list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBIWXMAAABIAAAASABGyWs+AAAACXZwQWcAAAAQAAAAEABcxq3DAAAA+klEQVQ4y63TMWvCQBjG8f8r+RouRSe7ONchuCTQsZS6FNLBSQrdi1PmFhcnhwpOUjoK3iIvRWcdnP0q16FcSOSijfSZjht+PNy9r6gqAGEYWgBVFSpEANtq1nl7TQCIk5Q8dA4OAPZmxHX0AsByOiw0Wk6HRJ020vBDAlh7+MouHOQauUSdNma9JU7SAhIcV9qbkRcy6633DQJKUgb9GTiG5Ooue598aucAAPO9A+Djc3UZcCr/A/Se37OalQFVlfliQ5ykF0E1+J2uKtCg/2jdufCN+fmfLzY83N7wdN8tRcaTmXjnwAeVRdw6n4pbLIBWsw7AeDITgB/OTHEfF3G6oAAAAABJRU5ErkJggg==);
+}
+ol.files li.symlink{
+ /* small/forward.png */
+ list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACtSURBVCjPfZGxEYMwDEWffNTswApcGs+RY4KUtDSZgIbWJRNwWSHnjibHCtkhCyiFDYYE+Krk/0/S9xdPQq4f4QfZuukpFC6yI3jpIz4UexPuyiFMrsMJDebJOcwbx5WSMl4QTsyXqVkBNFig5AagwU+1dTECI446unBM2ga7nXJQVj0ePPWpxEAlHfbQhfglg0EnxhVlaSUK5qhg/pUm0htBkkFPJasV/0jBfwHsdko9kUCoMwAAAABJRU5ErkJggg==);
+}
+ol.files li.regular-file{
+ /* small/text.png */
+ list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACDSURBVCjPYzzAgB8woQuc+Y/KZzz33xCJe55BkmEpgwkjHhMkGKJRTMFQcJ7hOYMJI0IJCwMDA4MVXPoYhP2fgcEEWcE6JBOOMRxkZGBYjWpCEJI0A4P9/xdIGtBMeMHAwCDBIIGuIIgBFRzDbQImoJcJx/CZ8Imx9/99DOFzDKFQFgBj6yGwhtEzOwAAAABJRU5ErkJggg==)
+}
+ol.files li.executable-file{
+ /* small/patch.png */
+ list-style-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACDSURBVCjPlY+xDYMwEEW/I4agZYRQsgojkC4ZAzp2ojMjuPQYjwJEcvgSKV+yzrKenv9J/2bk8hD5TCRjkdvVUKs3lgJYlfUKbyRE7gbojrkEtwOAJA18/UKC7HWYjrMXLYBJSQ+lEymAp+x0DHY6hkazGsfgrAnwc02bqg0jqfSetw2j7lch7TZvfQAAACR0RVh0Y29tbWVudABieSBNYXJ0aW4uS3JhZW1lckBNY2guU05JLkRlaCPF6QAAAABJRU5ErkJggg==);
+}
+ol.files li.gitlink{
+ /* small/continued.png */
+ list-style-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAAEgAAABIAEbJaz4AAAAJdnBBZwAAABAAAAAQAFzGrcMAAACPSURBVCjPzdCxbcNAFIPh7wQvkTIjBEjj1iscXGUSTZDGLjVCKuFWCK5Lc/AKGoUppNq1Wf18IB4Ilu65Ji8SmDOy2znzQeOgiTmLZoQ1VbWGkWYxh2mk+vBdGh6+XDzQLN5UIyc2d9vx+Ad/4BdNpTtHzrmlu0UctF+70rHm3WeBNVzLXnJzLfbAKyz5TP/rekGK5PrZfgAAAABJRU5ErkJggg==);
+}
+
+
+/* Styles for the path detail page. */
+
+.symlink-detail, .gitlink-detail {
+ margin-left: 1em;
+ color: #666;
+ font-style: italic;
+ font-size: 10pt;
+}
+
+
+/* Styles for the log detail page. */
+
+ol.shortlog {
+ list-style-type: none;
+ margin: 0;
+ padding: 5px 2em; /* PADDING */
+}
+ol.shortlog li {
+ border-bottom: #ddd solid 1px; /* BORDER */
+ padding-top: 2px;
+ padding-bottom: 2px;
+}
+ol.shortlog li.first {
+ border-top: #ddd solid 1px; /* BORDER */
+}
+ol.shortlog li:hover {
+ background: #eee; /* HOVER_BACKGROUND */
+}
+ol.shortlog .sha1 {
+ font-family: monospace;
+}
+.log-nav {
+ margin-top: 5px;
+ text-align: center;
+}
+.author {
+ padding-left: 3px;
+}
+.time {
+ font-size: 9pt; /* SHORTLOG_SMALL_FONT_SIZE */
+ font-style: italic;
+}
+.branch-label, .tag-label {
+ font-size: 9pt; /* SHORTLOG_SMALL_FONT_SIZE */
+ margin-left: 3px;
+}
+a.branch-label {
+ color: #dd4b39;
+}
+a.tag-label {
+ color: #009933;
+}
+
+
+/* Styles for the diff detail template. */
+
+.diff-header {
+}
+.diff-git {
+ color: #444;
+ font-weight: bold;
+}
+a.diff-git:hover {
+ text-decoration: none;
+}
+.diff-header, .diff-unified {
+ color: #000;
+ font-size: 9pt;
+ margin: 0;
+ padding-left: 2em;
+}
+.diff-unified {
+ border-bottom: #ddd solid 1px; /* BORDER */
+}
+.diff-unified .h {
+ color: darkblue;
+}
+.diff-unified .d {
+ color: darkred;
+}
+.diff-unified .i {
+ color: darkgreen;
+}
+
+
+/* Override some styles from the default prettify.css. */
+
+/* Line numbers on all lines. */
+li.L0, li.L1, li.L2, li.L3, li.L4, li.L5, li.L6, li.L7, li.L8, li.L9 {
+ list-style-type: decimal;
+}
+
+/* Disable alternating line background color. */
+li.L0, li.L1, li.L2, li.L3, li.L4, li.L5, li.L6, li.L7, li.L8, li.L9 {
+ background: #fff;
+}
+
+pre.git-blob {
+ border-top: #ddd solid 1px; /* BORDER */
+ border-bottom: #ddd solid 1px; /* BORDER */
+ border-left: none;
+ border-right: none;
+ padding-left: 1em;
+ padding-bottom: 5px;
+ font-family: monospace;
+ font-size: 8pt;
+}
+pre.prettyprint ol {
+ color: grey;
+}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/COPYING b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/COPYING
new file mode 100644
index 0000000..37a41c0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/COPYING
@@ -0,0 +1,214 @@
+google-code-prettify downloaded from:
+http://google-code-prettify.googlecode.com/files/prettify-small-1-Jun-2011.tar.bz2
+
+All files under the Apache License, with the following copyrights:
+prettify.js: Copyright (C) 2006 Google Inc.
+lang-apollo.js: Copyright (C) 2009 Onno Hommes.
+lang-clj.js: * @license Copyright (C) 2011 Google Inc.
+lang-css.js: Copyright (C) 2009 Google Inc.
+lang-go.js: Copyright (C) 2010 Google Inc.
+lang-hs.js: Copyright (C) 2009 Google Inc.
+lang-lisp.js: Copyright (C) 2008 Google Inc.
+lang-lua.js: Copyright (C) 2008 Google Inc.
+lang-ml.js: Copyright (C) 2008 Google Inc.
+lang-n.js: Copyright (C) 2011 Zimin A.V.
+lang-proto.js: Copyright (C) 2006 Google Inc.
+lang-scala.js: Copyright (C) 2010 Google Inc.
+lang-sql.js: Copyright (C) 2008 Google Inc.
+lang-tex.js: Copyright (C) 2011 Martin S.
+lang-vb.js: Copyright (C) 2009 Google Inc.
+lang-wiki.js: Copyright (C) 2009 Google Inc.
+lang-xq.js: Copyright (C) 2011 Patrick Wied
+
+===============================================================================
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ Copyright 2011 Mike Samuel et al
+
+ 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.
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-apollo.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-apollo.js
new file mode 100644
index 0000000..7098baf
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-apollo.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["com",/^#[^\n\r]*/,null,"#"],["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,null,'"']],[["kwd",/^(?:ADS|AD|AUG|BZF|BZMF|CAE|CAF|CA|CCS|COM|CS|DAS|DCA|DCOM|DCS|DDOUBL|DIM|DOUBLE|DTCB|DTCF|DV|DXCH|EDRUPT|EXTEND|INCR|INDEX|NDX|INHINT|LXCH|MASK|MSK|MP|MSU|NOOP|OVSK|QXCH|RAND|READ|RELINT|RESUME|RETURN|ROR|RXOR|SQUARE|SU|TCR|TCAA|OVSK|TCF|TC|TS|WAND|WOR|WRITE|XCH|XLQ|XXALQ|ZL|ZQ|ADD|ADZ|SUB|SUZ|MPY|MPR|MPZ|DVP|COM|ABS|CLA|CLZ|LDQ|STO|STQ|ALS|LLS|LRS|TRA|TSQ|TMI|TOV|AXT|TIX|DLY|INP|OUT)\s/,
+null],["typ",/^(?:-?GENADR|=MINUS|2BCADR|VN|BOF|MM|-?2CADR|-?[1-6]DNADR|ADRES|BBCON|[ES]?BANK=?|BLOCK|BNKSUM|E?CADR|COUNT\*?|2?DEC\*?|-?DNCHAN|-?DNPTR|EQUALS|ERASE|MEMORY|2?OCT|REMADR|SETLOC|SUBRO|ORG|BSS|BES|SYN|EQU|DEFINE|END)\s/,null],["lit",/^'(?:-*(?:\w|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?)?/],["pln",/^-*(?:[!-z]|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?/],["pun",/^[^\w\t\n\r "'-);\\\xa0]+/]]),["apollo","agc","aea"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-clj.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-clj.js
new file mode 100644
index 0000000..542a220
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-clj.js
@@ -0,0 +1,18 @@
+/*
+ Copyright (C) 2011 Google Inc.
+
+ 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.
+*/
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["opn",/^[([{]+/,a,"([{"],["clo",/^[)\]}]+/,a,")]}"],["com",/^;[^\n\r]*/,a,";"],["pln",/^[\t\n\r \xa0]+/,a,"\t\n\r \xa0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,a,'"']],[["kwd",/^(?:def|if|do|let|quote|var|fn|loop|recur|throw|try|monitor-enter|monitor-exit|defmacro|defn|defn-|macroexpand|macroexpand-1|for|doseq|dosync|dotimes|and|or|when|not|assert|doto|proxy|defstruct|first|rest|cons|defprotocol|deftype|defrecord|reify|defmulti|defmethod|meta|with-meta|ns|in-ns|create-ns|import|intern|refer|alias|namespace|resolve|ref|deref|refset|new|set!|memfn|to-array|into-array|aset|gen-class|reduce|map|filter|find|nil?|empty?|hash-map|hash-set|vec|vector|seq|flatten|reverse|assoc|dissoc|list|list?|disj|get|union|difference|intersection|extend|extend-type|extend-protocol|prn)\b/,a],
+["typ",/^:[\dA-Za-z-]+/]]),["clj"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-css.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-css.js
new file mode 100644
index 0000000..041e1f5
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-css.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\f\r ]+/,null," \t\r\n"]],[["str",/^"(?:[^\n\f\r"\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*"/,null],["str",/^'(?:[^\n\f\r'\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*'/,null],["lang-css-str",/^url\(([^"')]*)\)/i],["kwd",/^(?:url|rgb|!important|@import|@page|@media|@charset|inherit)(?=[^\w-]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*)\s*:/i],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],["com",
+/^(?:<\!--|--\>)/],["lit",/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],["lit",/^#[\da-f]{3,6}/i],["pln",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i],["pun",/^[^\s\w"']+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[["kwd",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[["str",/^[^"')]+/]]),["css-str"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-go.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-go.js
new file mode 100644
index 0000000..fc18dc0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-go.js
@@ -0,0 +1 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["pln",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])+(?:'|$)|`[^`]*(?:`|$))/,null,"\"'"]],[["com",/^(?:\/\/[^\n\r]*|\/\*[\S\s]*?\*\/)/],["pln",/^(?:[^"'/`]|\/(?![*/]))+/]]),["go"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-hs.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-hs.js
new file mode 100644
index 0000000..9d77b08
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-hs.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t-\r ]+/,null,"\t\n\r "],["str",/^"(?:[^\n\f\r"\\]|\\[\S\s])*(?:"|$)/,null,'"'],["str",/^'(?:[^\n\f\r'\\]|\\[^&])'?/,null,"'"],["lit",/^(?:0o[0-7]+|0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i,null,"0123456789"]],[["com",/^(?:--+[^\n\f\r]*|{-(?:[^-]|-+[^}-])*-})/],["kwd",/^(?:case|class|data|default|deriving|do|else|if|import|in|infix|infixl|infixr|instance|let|module|newtype|of|then|type|where|_)(?=[^\d'A-Za-z]|$)/,
+null],["pln",/^(?:[A-Z][\w']*\.)*[A-Za-z][\w']*/],["pun",/^[^\d\t-\r "'A-Za-z]+/]]),["hs"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lisp.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lisp.js
new file mode 100644
index 0000000..02a30e8
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lisp.js
@@ -0,0 +1,3 @@
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["opn",/^\(+/,a,"("],["clo",/^\)+/,a,")"],["com",/^;[^\n\r]*/,a,";"],["pln",/^[\t\n\r \xa0]+/,a,"\t\n\r \xa0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,a,'"']],[["kwd",/^(?:block|c[ad]+r|catch|con[ds]|def(?:ine|un)|do|eq|eql|equal|equalp|eval-when|flet|format|go|if|labels|lambda|let|load-time-value|locally|macrolet|multiple-value-call|nil|progn|progv|quote|require|return-from|setq|symbol-macrolet|t|tagbody|the|throw|unwind)\b/,a],
+["lit",/^[+-]?(?:[#0]x[\da-f]+|\d+\/\d+|(?:\.\d+|\d+(?:\.\d*)?)(?:[de][+-]?\d+)?)/i],["lit",/^'(?:-*(?:\w|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?)?/],["pln",/^-*(?:[_a-z]|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?/i],["pun",/^[^\w\t\n\r "'-);\\\xa0]+/]]),["cl","el","lisp","scm"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lua.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lua.js
new file mode 100644
index 0000000..e83a3c4
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-lua.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$))/,null,"\"'"]],[["com",/^--(?:\[(=*)\[[\S\s]*?(?:]\1]|$)|[^\n\r]*)/],["str",/^\[(=*)\[[\S\s]*?(?:]\1]|$)/],["kwd",/^(?:and|break|do|else|elseif|end|false|for|function|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/,null],["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],
+["pln",/^[_a-z]\w*/i],["pun",/^[^\w\t\n\r \xa0][^\w\t\n\r "'+=\xa0-]*/]]),["lua"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-ml.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-ml.js
new file mode 100644
index 0000000..6df02d7
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-ml.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["com",/^#(?:if[\t\n\r \xa0]+(?:[$_a-z][\w']*|``[^\t\n\r`]*(?:``|$))|else|endif|light)/i,null,"#"],["str",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])(?:'|$))/,null,"\"'"]],[["com",/^(?:\/\/[^\n\r]*|\(\*[\S\s]*?\*\))/],["kwd",/^(?:abstract|and|as|assert|begin|class|default|delegate|do|done|downcast|downto|elif|else|end|exception|extern|false|finally|for|fun|function|if|in|inherit|inline|interface|internal|lazy|let|match|member|module|mutable|namespace|new|null|of|open|or|override|private|public|rec|return|static|struct|then|to|true|try|type|upcast|use|val|void|when|while|with|yield|asr|land|lor|lsl|lsr|lxor|mod|sig|atomic|break|checked|component|const|constraint|constructor|continue|eager|event|external|fixed|functor|global|include|method|mixin|object|parallel|process|protected|pure|sealed|trait|virtual|volatile)\b/],
+["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],["pln",/^(?:[_a-z][\w']*[!#?]?|``[^\t\n\r`]*(?:``|$))/i],["pun",/^[^\w\t\n\r "'\xa0]+/]]),["fs","ml"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-n.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-n.js
new file mode 100644
index 0000000..6c2e85b
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-n.js
@@ -0,0 +1,4 @@
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["str",/^(?:'(?:[^\n\r'\\]|\\.)*'|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,a,'"'],["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,a,"#"],["pln",/^\s+/,a," \r\n\t\xa0"]],[["str",/^@"(?:[^"]|"")*(?:"|$)/,a],["str",/^<#[^#>]*(?:#>|$)/,a],["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,a],["com",/^\/\/[^\n\r]*/,a],["com",/^\/\*[\S\s]*?(?:\*\/|$)/,
+a],["kwd",/^(?:abstract|and|as|base|catch|class|def|delegate|enum|event|extern|false|finally|fun|implements|interface|internal|is|macro|match|matches|module|mutable|namespace|new|null|out|override|params|partial|private|protected|public|ref|sealed|static|struct|syntax|this|throw|true|try|type|typeof|using|variant|virtual|volatile|when|where|with|assert|assert2|async|break|checked|continue|do|else|ensures|for|foreach|if|late|lock|new|nolate|otherwise|regexp|repeat|requires|return|surroundwith|unchecked|unless|using|while|yield)\b/,
+a],["typ",/^(?:array|bool|byte|char|decimal|double|float|int|list|long|object|sbyte|short|string|ulong|uint|ufloat|ulong|ushort|void)\b/,a],["lit",/^@[$_a-z][\w$@]*/i,a],["typ",/^@[A-Z]+[a-z][\w$@]*/,a],["pln",/^'?[$_a-z][\w$@]*/i,a],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,a,"0123456789"],["pun",/^.[^\s\w"-$'./@`]*/,a]]),["n","nemerle"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-proto.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-proto.js
new file mode 100644
index 0000000..f006ad8
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-proto.js
@@ -0,0 +1 @@
+PR.registerLangHandler(PR.sourceDecorator({keywords:"bytes,default,double,enum,extend,extensions,false,group,import,max,message,option,optional,package,repeated,required,returns,rpc,service,syntax,to,true",types:/^(bool|(double|s?fixed|[su]?int)(32|64)|float|string)\b/,cStyleComments:!0}),["proto"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-scala.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-scala.js
new file mode 100644
index 0000000..60d034d
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-scala.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^"(?:""(?:""?(?!")|[^"\\]|\\.)*"{0,3}|(?:[^\n\r"\\]|\\.)*"?)/,null,'"'],["lit",/^`(?:[^\n\r\\`]|\\.)*`?/,null,"`"],["pun",/^[!#%&(--:-@[-^{-~]+/,null,"!#%&()*+,-:;<=>?@[\\]^{|}~"]],[["str",/^'(?:[^\n\r'\\]|\\(?:'|[^\n\r']+))'/],["lit",/^'[$A-Z_a-z][\w$]*(?![\w$'])/],["kwd",/^(?:abstract|case|catch|class|def|do|else|extends|final|finally|for|forSome|if|implicit|import|lazy|match|new|object|override|package|private|protected|requires|return|sealed|super|throw|trait|try|type|val|var|while|with|yield)\b/],
+["lit",/^(?:true|false|null|this)\b/],["lit",/^(?:0(?:[0-7]+|x[\da-f]+)l?|(?:0|[1-9]\d*)(?:(?:\.\d+)?(?:e[+-]?\d+)?f?|l?)|\\.\d+(?:e[+-]?\d+)?f?)/i],["typ",/^[$_]*[A-Z][\d$A-Z_]*[a-z][\w$]*/],["pln",/^[$A-Z_a-z][\w$]*/],["com",/^\/(?:\/.*|\*(?:\/|\**[^*/])*(?:\*+\/?)?)/],["pun",/^(?:\.+|\/)/]]),["scala"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-sql.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-sql.js
new file mode 100644
index 0000000..da705b0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-sql.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["str",/^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/,null,"\"'"]],[["com",/^(?:--[^\n\r]*|\/\*[\S\s]*?(?:\*\/|$))/],["kwd",/^(?:add|all|alter|and|any|as|asc|authorization|backup|begin|between|break|browse|bulk|by|cascade|case|check|checkpoint|close|clustered|coalesce|collate|column|commit|compute|constraint|contains|containstable|continue|convert|create|cross|current|current_date|current_time|current_timestamp|current_user|cursor|database|dbcc|deallocate|declare|default|delete|deny|desc|disk|distinct|distributed|double|drop|dummy|dump|else|end|errlvl|escape|except|exec|execute|exists|exit|fetch|file|fillfactor|for|foreign|freetext|freetexttable|from|full|function|goto|grant|group|having|holdlock|identity|identitycol|identity_insert|if|in|index|inner|insert|intersect|into|is|join|key|kill|left|like|lineno|load|match|merge|national|nocheck|nonclustered|not|null|nullif|of|off|offsets|on|open|opendatasource|openquery|openrowset|openxml|option|or|order|outer|over|percent|plan|precision|primary|print|proc|procedure|public|raiserror|read|readtext|reconfigure|references|replication|restore|restrict|return|revoke|right|rollback|rowcount|rowguidcol|rule|save|schema|select|session_user|set|setuser|shutdown|some|statistics|system_user|table|textsize|then|to|top|tran|transaction|trigger|truncate|tsequal|union|unique|update|updatetext|use|user|using|values|varying|view|waitfor|when|where|while|with|writetext)(?=[^\w-]|$)/i,
+null],["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],["pln",/^[_a-z][\w-]*/i],["pun",/^[^\w\t\n\r "'\xa0][^\w\t\n\r "'+\xa0-]*/]]),["sql"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-tex.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-tex.js
new file mode 100644
index 0000000..ce96fbb
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-tex.js
@@ -0,0 +1 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"],["com",/^%[^\n\r]*/,null,"%"]],[["kwd",/^\\[@-Za-z]+/],["kwd",/^\\./],["typ",/^[$&]/],["lit",/[+-]?(?:\.\d+|\d+(?:\.\d*)?)(cm|em|ex|in|pc|pt|bp|mm)/i],["pun",/^[()=[\]{}]+/]]),["latex","tex"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vb.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vb.js
new file mode 100644
index 0000000..07506b0
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vb.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0\u2028\u2029]+/,null,"\t\n\r Â\xa0â¨â©"],["str",/^(?:["\u201c\u201d](?:[^"\u201c\u201d]|["\u201c\u201d]{2})(?:["\u201c\u201d]c|$)|["\u201c\u201d](?:[^"\u201c\u201d]|["\u201c\u201d]{2})*(?:["\u201c\u201d]|$))/i,null,'"ââ'],["com",/^['\u2018\u2019].*/,null,"'ââ"]],[["kwd",/^(?:addhandler|addressof|alias|and|andalso|ansi|as|assembly|auto|boolean|byref|byte|byval|call|case|catch|cbool|cbyte|cchar|cdate|cdbl|cdec|char|cint|class|clng|cobj|const|cshort|csng|cstr|ctype|date|decimal|declare|default|delegate|dim|directcast|do|double|each|else|elseif|end|endif|enum|erase|error|event|exit|finally|for|friend|function|get|gettype|gosub|goto|handles|if|implements|imports|in|inherits|integer|interface|is|let|lib|like|long|loop|me|mod|module|mustinherit|mustoverride|mybase|myclass|namespace|new|next|not|notinheritable|notoverridable|object|on|option|optional|or|orelse|overloads|overridable|overrides|paramarray|preserve|private|property|protected|public|raiseevent|readonly|redim|removehandler|resume|return|select|set|shadows|shared|short|single|static|step|stop|string|structure|sub|synclock|then|throw|to|try|typeof|unicode|until|variant|wend|when|while|with|withevents|writeonly|xor|endif|gosub|let|variant|wend)\b/i,
+null],["com",/^rem.*/i],["lit",/^(?:true\b|false\b|nothing\b|\d+(?:e[+-]?\d+[dfr]?|[dfilrs])?|(?:&h[\da-f]+|&o[0-7]+)[ils]?|\d*\.\d+(?:e[+-]?\d+)?[dfr]?|#\s+(?:\d+[/-]\d+[/-]\d+(?:\s+\d+:\d+(?::\d+)?(\s*(?:am|pm))?)?|\d+:\d+(?::\d+)?(\s*(?:am|pm))?)\s+#)/i],["pln",/^(?:(?:[a-z]|_\w)\w*|\[(?:[a-z]|_\w)\w*])/i],["pun",/^[^\w\t\n\r "'[\]\xa0\u2018\u2019\u201c\u201d\u2028\u2029]+/],["pun",/^(?:\[|])/]]),["vb","vbs"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vhdl.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vhdl.js
new file mode 100644
index 0000000..128b5b6
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-vhdl.js
@@ -0,0 +1,3 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r Â\xa0"]],[["str",/^(?:[box]?"(?:[^"]|"")*"|'.')/i],["com",/^--[^\n\r]*/],["kwd",/^(?:abs|access|after|alias|all|and|architecture|array|assert|attribute|begin|block|body|buffer|bus|case|component|configuration|constant|disconnect|downto|else|elsif|end|entity|exit|file|for|function|generate|generic|group|guarded|if|impure|in|inertial|inout|is|label|library|linkage|literal|loop|map|mod|nand|new|next|nor|not|null|of|on|open|or|others|out|package|port|postponed|procedure|process|pure|range|record|register|reject|rem|report|return|rol|ror|select|severity|shared|signal|sla|sll|sra|srl|subtype|then|to|transport|type|unaffected|units|until|use|variable|wait|when|while|with|xnor|xor)(?=[^\w-]|$)/i,
+null],["typ",/^(?:bit|bit_vector|character|boolean|integer|real|time|string|severity_level|positive|natural|signed|unsigned|line|text|std_u?logic(?:_vector)?)(?=[^\w-]|$)/i,null],["typ",/^'(?:active|ascending|base|delayed|driving|driving_value|event|high|image|instance_name|last_active|last_event|last_value|left|leftof|length|low|path_name|pos|pred|quiet|range|reverse_range|right|rightof|simple_name|stable|succ|transaction|val|value)(?=[^\w-]|$)/i,null],["lit",/^\d+(?:_\d+)*(?:#[\w.\\]+#(?:[+-]?\d+(?:_\d+)*)?|(?:\.\d+(?:_\d+)*)?(?:e[+-]?\d+(?:_\d+)*)?)/i],
+["pln",/^(?:[a-z]\w*|\\[^\\]*\\)/i],["pun",/^[^\w\t\n\r "'\xa0][^\w\t\n\r "'\xa0-]*/]]),["vhdl","vhd"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-wiki.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-wiki.js
new file mode 100644
index 0000000..9b0b448
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-wiki.js
@@ -0,0 +1,2 @@
+PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\d\t a-gi-z\xa0]+/,null,"\t Â\xa0abcdefgijklmnopqrstuvwxyz0123456789"],["pun",/^[*=[\]^~]+/,null,"=*~^[]"]],[["lang-wiki.meta",/(?:^^|\r\n?|\n)(#[a-z]+)\b/],["lit",/^[A-Z][a-z][\da-z]+[A-Z][a-z][^\W_]+\b/],["lang-",/^{{{([\S\s]+?)}}}/],["lang-",/^`([^\n\r`]+)`/],["str",/^https?:\/\/[^\s#/?]*(?:\/[^\s#?]*)?(?:\?[^\s#]*)?(?:#\S*)?/i],["pln",/^(?:\r\n|[\S\s])[^\n\r#*=A-[^`h{~]*/]]),["wiki"]);
+PR.registerLangHandler(PR.createSimpleLexer([["kwd",/^#[a-z]+/i,null,"#"]],[]),["wiki.meta"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-xq.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-xq.js
new file mode 100644
index 0000000..e323ae3
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-xq.js
@@ -0,0 +1,3 @@
+PR.registerLangHandler(PR.createSimpleLexer([["var pln",/^\$[\w-]+/,null,"$"]],[["pln",/^[\s=][<>][\s=]/],["lit",/^@[\w-]+/],["tag",/^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["com",/^\(:[\S\s]*?:\)/],["pln",/^[(),/;[\]{}]$/],["str",/^(?:"(?:[^"\\{]|\\[\S\s])*(?:"|$)|'(?:[^'\\{]|\\[\S\s])*(?:'|$))/,null,"\"'"],["kwd",/^(?:xquery|where|version|variable|union|typeswitch|treat|to|then|text|stable|sortby|some|self|schema|satisfies|returns|return|ref|processing-instruction|preceding-sibling|preceding|precedes|parent|only|of|node|namespace|module|let|item|intersect|instance|in|import|if|function|for|follows|following-sibling|following|external|except|every|else|element|descending|descendant-or-self|descendant|define|default|declare|comment|child|cast|case|before|attribute|assert|ascending|as|ancestor-or-self|ancestor|after|eq|order|by|or|and|schema-element|document-node|node|at)\b/],
+["typ",/^(?:xs:yearMonthDuration|xs:unsignedLong|xs:time|xs:string|xs:short|xs:QName|xs:Name|xs:long|xs:integer|xs:int|xs:gYearMonth|xs:gYear|xs:gMonthDay|xs:gDay|xs:float|xs:duration|xs:double|xs:decimal|xs:dayTimeDuration|xs:dateTime|xs:date|xs:byte|xs:boolean|xs:anyURI|xf:yearMonthDuration)\b/,null],["fun pln",/^(?:xp:dereference|xinc:node-expand|xinc:link-references|xinc:link-expand|xhtml:restructure|xhtml:clean|xhtml:add-lists|xdmp:zip-manifest|xdmp:zip-get|xdmp:zip-create|xdmp:xquery-version|xdmp:word-convert|xdmp:with-namespaces|xdmp:version|xdmp:value|xdmp:user-roles|xdmp:user-last-login|xdmp:user|xdmp:url-encode|xdmp:url-decode|xdmp:uri-is-file|xdmp:uri-format|xdmp:uri-content-type|xdmp:unquote|xdmp:unpath|xdmp:triggers-database|xdmp:trace|xdmp:to-json|xdmp:tidy|xdmp:subbinary|xdmp:strftime|xdmp:spawn-in|xdmp:spawn|xdmp:sleep|xdmp:shutdown|xdmp:set-session-field|xdmp:set-response-encoding|xdmp:set-response-content-type|xdmp:set-response-code|xdmp:set-request-time-limit|xdmp:set|xdmp:servers|xdmp:server-status|xdmp:server-name|xdmp:server|xdmp:security-database|xdmp:security-assert|xdmp:schema-database|xdmp:save|xdmp:role-roles|xdmp:role|xdmp:rethrow|xdmp:restart|xdmp:request-timestamp|xdmp:request-status|xdmp:request-cancel|xdmp:request|xdmp:redirect-response|xdmp:random|xdmp:quote|xdmp:query-trace|xdmp:query-meters|xdmp:product-edition|xdmp:privilege-roles|xdmp:privilege|xdmp:pretty-print|xdmp:powerpoint-convert|xdmp:platform|xdmp:permission|xdmp:pdf-convert|xdmp:path|xdmp:octal-to-integer|xdmp:node-uri|xdmp:node-replace|xdmp:node-kind|xdmp:node-insert-child|xdmp:node-insert-before|xdmp:node-insert-after|xdmp:node-delete|xdmp:node-database|xdmp:mul64|xdmp:modules-root|xdmp:modules-database|xdmp:merging|xdmp:merge-cancel|xdmp:merge|xdmp:md5|xdmp:logout|xdmp:login|xdmp:log-level|xdmp:log|xdmp:lock-release|xdmp:lock-acquire|xdmp:load|xdmp:invoke-in|xdmp:invoke|xdmp:integer-to-octal|xdmp:integer-to-hex|xdmp:http-put|xdmp:http-post|xdmp:http-options|xdmp:http-head|xdmp:http-get|xdmp:http-delete|xdmp:hosts|xdmp:host-status|xdmp:host-name|xdmp:host|xdmp:hex-to-integer|xdmp:hash64|xdmp:hash32|xdmp:has-privilege|xdmp:groups|xdmp:group-serves|xdmp:group-servers|xdmp:group-name|xdmp:group-hosts|xdmp:group|xdmp:get-session-field-names|xdmp:get-session-field|xdmp:get-response-encoding|xdmp:get-response-code|xdmp:get-request-username|xdmp:get-request-user|xdmp:get-request-url|xdmp:get-request-protocol|xdmp:get-request-path|xdmp:get-request-method|xdmp:get-request-header-names|xdmp:get-request-header|xdmp:get-request-field-names|xdmp:get-request-field-filename|xdmp:get-request-field-content-type|xdmp:get-request-field|xdmp:get-request-client-certificate|xdmp:get-request-client-address|xdmp:get-request-body|xdmp:get-current-user|xdmp:get-current-roles|xdmp:get|xdmp:function-name|xdmp:function-module|xdmp:function|xdmp:from-json|xdmp:forests|xdmp:forest-status|xdmp:forest-restore|xdmp:forest-restart|xdmp:forest-name|xdmp:forest-delete|xdmp:forest-databases|xdmp:forest-counts|xdmp:forest-clear|xdmp:forest-backup|xdmp:forest|xdmp:filesystem-file|xdmp:filesystem-directory|xdmp:exists|xdmp:excel-convert|xdmp:eval-in|xdmp:eval|xdmp:estimate|xdmp:email|xdmp:element-content-type|xdmp:elapsed-time|xdmp:document-set-quality|xdmp:document-set-property|xdmp:document-set-properties|xdmp:document-set-permissions|xdmp:document-set-collections|xdmp:document-remove-properties|xdmp:document-remove-permissions|xdmp:document-remove-collections|xdmp:document-properties|xdmp:document-locks|xdmp:document-load|xdmp:document-insert|xdmp:document-get-quality|xdmp:document-get-properties|xdmp:document-get-permissions|xdmp:document-get-collections|xdmp:document-get|xdmp:document-forest|xdmp:document-delete|xdmp:document-add-properties|xdmp:document-add-permissions|xdmp:document-add-collections|xdmp:directory-properties|xdmp:directory-locks|xdmp:directory-delete|xdmp:directory-create|xdmp:directory|xdmp:diacritic-less|xdmp:describe|xdmp:default-permissions|xdmp:default-collections|xdmp:databases|xdmp:database-restore-validate|xdmp:database-restore-status|xdmp:database-restore-cancel|xdmp:database-restore|xdmp:database-name|xdmp:database-forests|xdmp:database-backup-validate|xdmp:database-backup-status|xdmp:database-backup-purge|xdmp:database-backup-cancel|xdmp:database-backup|xdmp:database|xdmp:collection-properties|xdmp:collection-locks|xdmp:collection-delete|xdmp:collation-canonical-uri|xdmp:castable-as|xdmp:can-grant-roles|xdmp:base64-encode|xdmp:base64-decode|xdmp:architecture|xdmp:apply|xdmp:amp-roles|xdmp:amp|xdmp:add64|xdmp:add-response-header|xdmp:access|trgr:trigger-set-recursive|trgr:trigger-set-permissions|trgr:trigger-set-name|trgr:trigger-set-module|trgr:trigger-set-event|trgr:trigger-set-description|trgr:trigger-remove-permissions|trgr:trigger-module|trgr:trigger-get-permissions|trgr:trigger-enable|trgr:trigger-disable|trgr:trigger-database-online-event|trgr:trigger-data-event|trgr:trigger-add-permissions|trgr:remove-trigger|trgr:property-content|trgr:pre-commit|trgr:post-commit|trgr:get-trigger-by-id|trgr:get-trigger|trgr:document-scope|trgr:document-content|trgr:directory-scope|trgr:create-trigger|trgr:collection-scope|trgr:any-property-content|thsr:set-entry|thsr:remove-term|thsr:remove-synonym|thsr:remove-entry|thsr:query-lookup|thsr:lookup|thsr:load|thsr:insert|thsr:expand|thsr:add-synonym|spell:suggest-detailed|spell:suggest|spell:remove-word|spell:make-dictionary|spell:load|spell:levenshtein-distance|spell:is-correct|spell:insert|spell:double-metaphone|spell:add-word|sec:users-collection|sec:user-set-roles|sec:user-set-password|sec:user-set-name|sec:user-set-description|sec:user-set-default-permissions|sec:user-set-default-collections|sec:user-remove-roles|sec:user-privileges|sec:user-get-roles|sec:user-get-description|sec:user-get-default-permissions|sec:user-get-default-collections|sec:user-doc-permissions|sec:user-doc-collections|sec:user-add-roles|sec:unprotect-collection|sec:uid-for-name|sec:set-realm|sec:security-version|sec:security-namespace|sec:security-installed|sec:security-collection|sec:roles-collection|sec:role-set-roles|sec:role-set-name|sec:role-set-description|sec:role-set-default-permissions|sec:role-set-default-collections|sec:role-remove-roles|sec:role-privileges|sec:role-get-roles|sec:role-get-description|sec:role-get-default-permissions|sec:role-get-default-collections|sec:role-doc-permissions|sec:role-doc-collections|sec:role-add-roles|sec:remove-user|sec:remove-role-from-users|sec:remove-role-from-role|sec:remove-role-from-privileges|sec:remove-role-from-amps|sec:remove-role|sec:remove-privilege|sec:remove-amp|sec:protect-collection|sec:privileges-collection|sec:privilege-set-roles|sec:privilege-set-name|sec:privilege-remove-roles|sec:privilege-get-roles|sec:privilege-add-roles|sec:priv-doc-permissions|sec:priv-doc-collections|sec:get-user-names|sec:get-unique-elem-id|sec:get-role-names|sec:get-role-ids|sec:get-privilege|sec:get-distinct-permissions|sec:get-collection|sec:get-amp|sec:create-user-with-role|sec:create-user|sec:create-role|sec:create-privilege|sec:create-amp|sec:collections-collection|sec:collection-set-permissions|sec:collection-remove-permissions|sec:collection-get-permissions|sec:collection-add-permissions|sec:check-admin|sec:amps-collection|sec:amp-set-roles|sec:amp-remove-roles|sec:amp-get-roles|sec:amp-doc-permissions|sec:amp-doc-collections|sec:amp-add-roles|search:unparse|search:suggest|search:snippet|search:search|search:resolve-nodes|search:resolve|search:remove-constraint|search:parse|search:get-default-options|search:estimate|search:check-options|prof:value|prof:reset|prof:report|prof:invoke|prof:eval|prof:enable|prof:disable|prof:allowed|ppt:clean|pki:template-set-request|pki:template-set-name|pki:template-set-key-type|pki:template-set-key-options|pki:template-set-description|pki:template-in-use|pki:template-get-version|pki:template-get-request|pki:template-get-name|pki:template-get-key-type|pki:template-get-key-options|pki:template-get-id|pki:template-get-description|pki:need-certificate|pki:is-temporary|pki:insert-trusted-certificates|pki:insert-template|pki:insert-signed-certificates|pki:insert-certificate-revocation-list|pki:get-trusted-certificate-ids|pki:get-template-ids|pki:get-template-certificate-authority|pki:get-template-by-name|pki:get-template|pki:get-pending-certificate-requests-xml|pki:get-pending-certificate-requests-pem|pki:get-pending-certificate-request|pki:get-certificates-for-template-xml|pki:get-certificates-for-template|pki:get-certificates|pki:get-certificate-xml|pki:get-certificate-pem|pki:get-certificate|pki:generate-temporary-certificate-if-necessary|pki:generate-temporary-certificate|pki:generate-template-certificate-authority|pki:generate-certificate-request|pki:delete-template|pki:delete-certificate|pki:create-template|pdf:make-toc|pdf:insert-toc-headers|pdf:get-toc|pdf:clean|p:status-transition|p:state-transition|p:remove|p:pipelines|p:insert|p:get-by-id|p:get|p:execute|p:create|p:condition|p:collection|p:action|ooxml:runs-merge|ooxml:package-uris|ooxml:package-parts-insert|ooxml:package-parts|msword:clean|mcgm:polygon|mcgm:point|mcgm:geospatial-query-from-elements|mcgm:geospatial-query|mcgm:circle|math:tanh|math:tan|math:sqrt|math:sinh|math:sin|math:pow|math:modf|math:log10|math:log|math:ldexp|math:frexp|math:fmod|math:floor|math:fabs|math:exp|math:cosh|math:cos|math:ceil|math:atan2|math:atan|math:asin|math:acos|map:put|map:map|map:keys|map:get|map:delete|map:count|map:clear|lnk:to|lnk:remove|lnk:insert|lnk:get|lnk:from|lnk:create|kml:polygon|kml:point|kml:interior-polygon|kml:geospatial-query-from-elements|kml:geospatial-query|kml:circle|kml:box|gml:polygon|gml:point|gml:interior-polygon|gml:geospatial-query-from-elements|gml:geospatial-query|gml:circle|gml:box|georss:point|georss:geospatial-query|georss:circle|geo:polygon|geo:point|geo:interior-polygon|geo:geospatial-query-from-elements|geo:geospatial-query|geo:circle|geo:box|fn:zero-or-one|fn:years-from-duration|fn:year-from-dateTime|fn:year-from-date|fn:upper-case|fn:unordered|fn:true|fn:translate|fn:trace|fn:tokenize|fn:timezone-from-time|fn:timezone-from-dateTime|fn:timezone-from-date|fn:sum|fn:subtract-dateTimes-yielding-yearMonthDuration|fn:subtract-dateTimes-yielding-dayTimeDuration|fn:substring-before|fn:substring-after|fn:substring|fn:subsequence|fn:string-to-codepoints|fn:string-pad|fn:string-length|fn:string-join|fn:string|fn:static-base-uri|fn:starts-with|fn:seconds-from-time|fn:seconds-from-duration|fn:seconds-from-dateTime|fn:round-half-to-even|fn:round|fn:root|fn:reverse|fn:resolve-uri|fn:resolve-QName|fn:replace|fn:remove|fn:QName|fn:prefix-from-QName|fn:position|fn:one-or-more|fn:number|fn:not|fn:normalize-unicode|fn:normalize-space|fn:node-name|fn:node-kind|fn:nilled|fn:namespace-uri-from-QName|fn:namespace-uri-for-prefix|fn:namespace-uri|fn:name|fn:months-from-duration|fn:month-from-dateTime|fn:month-from-date|fn:minutes-from-time|fn:minutes-from-duration|fn:minutes-from-dateTime|fn:min|fn:max|fn:matches|fn:lower-case|fn:local-name-from-QName|fn:local-name|fn:last|fn:lang|fn:iri-to-uri|fn:insert-before|fn:index-of|fn:in-scope-prefixes|fn:implicit-timezone|fn:idref|fn:id|fn:hours-from-time|fn:hours-from-duration|fn:hours-from-dateTime|fn:floor|fn:false|fn:expanded-QName|fn:exists|fn:exactly-one|fn:escape-uri|fn:escape-html-uri|fn:error|fn:ends-with|fn:encode-for-uri|fn:empty|fn:document-uri|fn:doc-available|fn:doc|fn:distinct-values|fn:distinct-nodes|fn:default-collation|fn:deep-equal|fn:days-from-duration|fn:day-from-dateTime|fn:day-from-date|fn:data|fn:current-time|fn:current-dateTime|fn:current-date|fn:count|fn:contains|fn:concat|fn:compare|fn:collection|fn:codepoints-to-string|fn:codepoint-equal|fn:ceiling|fn:boolean|fn:base-uri|fn:avg|fn:adjust-time-to-timezone|fn:adjust-dateTime-to-timezone|fn:adjust-date-to-timezone|fn:abs|feed:unsubscribe|feed:subscription|feed:subscribe|feed:request|feed:item|feed:description|excel:clean|entity:enrich|dom:set-pipelines|dom:set-permissions|dom:set-name|dom:set-evaluation-context|dom:set-domain-scope|dom:set-description|dom:remove-pipeline|dom:remove-permissions|dom:remove|dom:get|dom:evaluation-context|dom:domains|dom:domain-scope|dom:create|dom:configuration-set-restart-user|dom:configuration-set-permissions|dom:configuration-set-evaluation-context|dom:configuration-set-default-domain|dom:configuration-get|dom:configuration-create|dom:collection|dom:add-pipeline|dom:add-permissions|dls:retention-rules|dls:retention-rule-remove|dls:retention-rule-insert|dls:retention-rule|dls:purge|dls:node-expand|dls:link-references|dls:link-expand|dls:documents-query|dls:document-versions-query|dls:document-version-uri|dls:document-version-query|dls:document-version-delete|dls:document-version-as-of|dls:document-version|dls:document-update|dls:document-unmanage|dls:document-set-quality|dls:document-set-property|dls:document-set-properties|dls:document-set-permissions|dls:document-set-collections|dls:document-retention-rules|dls:document-remove-properties|dls:document-remove-permissions|dls:document-remove-collections|dls:document-purge|dls:document-manage|dls:document-is-managed|dls:document-insert-and-manage|dls:document-include-query|dls:document-history|dls:document-get-permissions|dls:document-extract-part|dls:document-delete|dls:document-checkout-status|dls:document-checkout|dls:document-checkin|dls:document-add-properties|dls:document-add-permissions|dls:document-add-collections|dls:break-checkout|dls:author-query|dls:as-of-query|dbk:convert|dbg:wait|dbg:value|dbg:stopped|dbg:stop|dbg:step|dbg:status|dbg:stack|dbg:out|dbg:next|dbg:line|dbg:invoke|dbg:function|dbg:finish|dbg:expr|dbg:eval|dbg:disconnect|dbg:detach|dbg:continue|dbg:connect|dbg:clear|dbg:breakpoints|dbg:break|dbg:attached|dbg:attach|cvt:save-converted-documents|cvt:part-uri|cvt:destination-uri|cvt:basepath|cvt:basename|cts:words|cts:word-query-weight|cts:word-query-text|cts:word-query-options|cts:word-query|cts:word-match|cts:walk|cts:uris|cts:uri-match|cts:train|cts:tokenize|cts:thresholds|cts:stem|cts:similar-query-weight|cts:similar-query-nodes|cts:similar-query|cts:shortest-distance|cts:search|cts:score|cts:reverse-query-weight|cts:reverse-query-nodes|cts:reverse-query|cts:remainder|cts:registered-query-weight|cts:registered-query-options|cts:registered-query-ids|cts:registered-query|cts:register|cts:query|cts:quality|cts:properties-query-query|cts:properties-query|cts:polygon-vertices|cts:polygon|cts:point-longitude|cts:point-latitude|cts:point|cts:or-query-queries|cts:or-query|cts:not-query-weight|cts:not-query-query|cts:not-query|cts:near-query-weight|cts:near-query-queries|cts:near-query-options|cts:near-query-distance|cts:near-query|cts:highlight|cts:geospatial-co-occurrences|cts:frequency|cts:fitness|cts:field-words|cts:field-word-query-weight|cts:field-word-query-text|cts:field-word-query-options|cts:field-word-query-field-name|cts:field-word-query|cts:field-word-match|cts:entity-highlight|cts:element-words|cts:element-word-query-weight|cts:element-word-query-text|cts:element-word-query-options|cts:element-word-query-element-name|cts:element-word-query|cts:element-word-match|cts:element-values|cts:element-value-ranges|cts:element-value-query-weight|cts:element-value-query-text|cts:element-value-query-options|cts:element-value-query-element-name|cts:element-value-query|cts:element-value-match|cts:element-value-geospatial-co-occurrences|cts:element-value-co-occurrences|cts:element-range-query-weight|cts:element-range-query-value|cts:element-range-query-options|cts:element-range-query-operator|cts:element-range-query-element-name|cts:element-range-query|cts:element-query-query|cts:element-query-element-name|cts:element-query|cts:element-pair-geospatial-values|cts:element-pair-geospatial-value-match|cts:element-pair-geospatial-query-weight|cts:element-pair-geospatial-query-region|cts:element-pair-geospatial-query-options|cts:element-pair-geospatial-query-longitude-name|cts:element-pair-geospatial-query-latitude-name|cts:element-pair-geospatial-query-element-name|cts:element-pair-geospatial-query|cts:element-pair-geospatial-boxes|cts:element-geospatial-values|cts:element-geospatial-value-match|cts:element-geospatial-query-weight|cts:element-geospatial-query-region|cts:element-geospatial-query-options|cts:element-geospatial-query-element-name|cts:element-geospatial-query|cts:element-geospatial-boxes|cts:element-child-geospatial-values|cts:element-child-geospatial-value-match|cts:element-child-geospatial-query-weight|cts:element-child-geospatial-query-region|cts:element-child-geospatial-query-options|cts:element-child-geospatial-query-element-name|cts:element-child-geospatial-query-child-name|cts:element-child-geospatial-query|cts:element-child-geospatial-boxes|cts:element-attribute-words|cts:element-attribute-word-query-weight|cts:element-attribute-word-query-text|cts:element-attribute-word-query-options|cts:element-attribute-word-query-element-name|cts:element-attribute-word-query-attribute-name|cts:element-attribute-word-query|cts:element-attribute-word-match|cts:element-attribute-values|cts:element-attribute-value-ranges|cts:element-attribute-value-query-weight|cts:element-attribute-value-query-text|cts:element-attribute-value-query-options|cts:element-attribute-value-query-element-name|cts:element-attribute-value-query-attribute-name|cts:element-attribute-value-query|cts:element-attribute-value-match|cts:element-attribute-value-geospatial-co-occurrences|cts:element-attribute-value-co-occurrences|cts:element-attribute-range-query-weight|cts:element-attribute-range-query-value|cts:element-attribute-range-query-options|cts:element-attribute-range-query-operator|cts:element-attribute-range-query-element-name|cts:element-attribute-range-query-attribute-name|cts:element-attribute-range-query|cts:element-attribute-pair-geospatial-values|cts:element-attribute-pair-geospatial-value-match|cts:element-attribute-pair-geospatial-query-weight|cts:element-attribute-pair-geospatial-query-region|cts:element-attribute-pair-geospatial-query-options|cts:element-attribute-pair-geospatial-query-longitude-name|cts:element-attribute-pair-geospatial-query-latitude-name|cts:element-attribute-pair-geospatial-query-element-name|cts:element-attribute-pair-geospatial-query|cts:element-attribute-pair-geospatial-boxes|cts:document-query-uris|cts:document-query|cts:distance|cts:directory-query-uris|cts:directory-query-depth|cts:directory-query|cts:destination|cts:deregister|cts:contains|cts:confidence|cts:collections|cts:collection-query-uris|cts:collection-query|cts:collection-match|cts:classify|cts:circle-radius|cts:circle-center|cts:circle|cts:box-west|cts:box-south|cts:box-north|cts:box-east|cts:box|cts:bearing|cts:arc-intersection|cts:and-query-queries|cts:and-query-options|cts:and-query|cts:and-not-query-positive-query|cts:and-not-query-negative-query|cts:and-not-query|css:get|css:convert|cpf:success|cpf:failure|cpf:document-set-state|cpf:document-set-processing-status|cpf:document-set-last-updated|cpf:document-set-error|cpf:document-get-state|cpf:document-get-processing-status|cpf:document-get-last-updated|cpf:document-get-error|cpf:check-transition|alert:spawn-matching-actions|alert:rule-user-id-query|alert:rule-set-user-id|alert:rule-set-query|alert:rule-set-options|alert:rule-set-name|alert:rule-set-description|alert:rule-set-action|alert:rule-remove|alert:rule-name-query|alert:rule-insert|alert:rule-id-query|alert:rule-get-user-id|alert:rule-get-query|alert:rule-get-options|alert:rule-get-name|alert:rule-get-id|alert:rule-get-description|alert:rule-get-action|alert:rule-action-query|alert:remove-triggers|alert:make-rule|alert:make-log-action|alert:make-config|alert:make-action|alert:invoke-matching-actions|alert:get-my-rules|alert:get-all-rules|alert:get-actions|alert:find-matching-rules|alert:create-triggers|alert:config-set-uri|alert:config-set-trigger-ids|alert:config-set-options|alert:config-set-name|alert:config-set-description|alert:config-set-cpf-domain-names|alert:config-set-cpf-domain-ids|alert:config-insert|alert:config-get-uri|alert:config-get-trigger-ids|alert:config-get-options|alert:config-get-name|alert:config-get-id|alert:config-get-description|alert:config-get-cpf-domain-names|alert:config-get-cpf-domain-ids|alert:config-get|alert:config-delete|alert:action-set-options|alert:action-set-name|alert:action-set-module-root|alert:action-set-module-db|alert:action-set-module|alert:action-set-description|alert:action-remove|alert:action-insert|alert:action-get-options|alert:action-get-name|alert:action-get-module-root|alert:action-get-module-db|alert:action-get-module|alert:action-get-description|zero-or-one|years-from-duration|year-from-dateTime|year-from-date|upper-case|unordered|true|translate|trace|tokenize|timezone-from-time|timezone-from-dateTime|timezone-from-date|sum|subtract-dateTimes-yielding-yearMonthDuration|subtract-dateTimes-yielding-dayTimeDuration|substring-before|substring-after|substring|subsequence|string-to-codepoints|string-pad|string-length|string-join|string|static-base-uri|starts-with|seconds-from-time|seconds-from-duration|seconds-from-dateTime|round-half-to-even|round|root|reverse|resolve-uri|resolve-QName|replace|remove|QName|prefix-from-QName|position|one-or-more|number|not|normalize-unicode|normalize-space|node-name|node-kind|nilled|namespace-uri-from-QName|namespace-uri-for-prefix|namespace-uri|name|months-from-duration|month-from-dateTime|month-from-date|minutes-from-time|minutes-from-duration|minutes-from-dateTime|min|max|matches|lower-case|local-name-from-QName|local-name|last|lang|iri-to-uri|insert-before|index-of|in-scope-prefixes|implicit-timezone|idref|id|hours-from-time|hours-from-duration|hours-from-dateTime|floor|false|expanded-QName|exists|exactly-one|escape-uri|escape-html-uri|error|ends-with|encode-for-uri|empty|document-uri|doc-available|doc|distinct-values|distinct-nodes|default-collation|deep-equal|days-from-duration|day-from-dateTime|day-from-date|data|current-time|current-dateTime|current-date|count|contains|concat|compare|collection|codepoints-to-string|codepoint-equal|ceiling|boolean|base-uri|avg|adjust-time-to-timezone|adjust-dateTime-to-timezone|adjust-date-to-timezone|abs)\b/],
+["pln",/^[\w:-]+/],["pln",/^[\t\n\r \xa0]+/]]),["xq","xquery"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-yaml.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-yaml.js
new file mode 100644
index 0000000..c38729b
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/lang-yaml.js
@@ -0,0 +1,2 @@
+var a=null;
+PR.registerLangHandler(PR.createSimpleLexer([["pun",/^[:>?|]+/,a,":|>?"],["dec",/^%(?:YAML|TAG)[^\n\r#]+/,a,"%"],["typ",/^&\S+/,a,"&"],["typ",/^!\S*/,a,"!"],["str",/^"(?:[^"\\]|\\.)*(?:"|$)/,a,'"'],["str",/^'(?:[^']|'')*(?:'|$)/,a,"'"],["com",/^#[^\n\r]*/,a,"#"],["pln",/^\s+/,a," \t\r\n"]],[["dec",/^(?:---|\.\.\.)(?:[\n\r]|$)/],["pun",/^-/],["kwd",/^\w+:[\n\r ]/],["pln",/^\w+/]]),["yaml","yml"]);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.css
new file mode 100644
index 0000000..d44b3a2
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.css
@@ -0,0 +1 @@
+.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
\ No newline at end of file
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.js b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.js
new file mode 100644
index 0000000..eef5ad7
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/prettify/prettify.js
@@ -0,0 +1,28 @@
+var q=null;window.PR_SHOULD_USE_CONTINUATION=!0;
+(function(){function L(a){function m(a){var f=a.charCodeAt(0);if(f!==92)return f;var b=a.charAt(1);return(f=r[b])?f:"0"<=b&&b<="7"?parseInt(a.substring(1),8):b==="u"||b==="x"?parseInt(a.substring(2),16):a.charCodeAt(1)}function e(a){if(a<32)return(a<16?"\\x0":"\\x")+a.toString(16);a=String.fromCharCode(a);if(a==="\\"||a==="-"||a==="["||a==="]")a="\\"+a;return a}function h(a){for(var f=a.substring(1,a.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),a=
+[],b=[],o=f[0]==="^",c=o?1:0,i=f.length;c<i;++c){var j=f[c];if(/\\[bdsw]/i.test(j))a.push(j);else{var j=m(j),d;c+2<i&&"-"===f[c+1]?(d=m(f[c+2]),c+=2):d=j;b.push([j,d]);d<65||j>122||(d<65||j>90||b.push([Math.max(65,j)|32,Math.min(d,90)|32]),d<97||j>122||b.push([Math.max(97,j)&-33,Math.min(d,122)&-33]))}}b.sort(function(a,f){return a[0]-f[0]||f[1]-a[1]});f=[];j=[NaN,NaN];for(c=0;c<b.length;++c)i=b[c],i[0]<=j[1]+1?j[1]=Math.max(j[1],i[1]):f.push(j=i);b=["["];o&&b.push("^");b.push.apply(b,a);for(c=0;c<
+f.length;++c)i=f[c],b.push(e(i[0])),i[1]>i[0]&&(i[1]+1>i[0]&&b.push("-"),b.push(e(i[1])));b.push("]");return b.join("")}function y(a){for(var f=a.source.match(/\[(?:[^\\\]]|\\[\S\s])*]|\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\\d+|\\[^\dux]|\(\?[!:=]|[()^]|[^()[\\^]+/g),b=f.length,d=[],c=0,i=0;c<b;++c){var j=f[c];j==="("?++i:"\\"===j.charAt(0)&&(j=+j.substring(1))&&j<=i&&(d[j]=-1)}for(c=1;c<d.length;++c)-1===d[c]&&(d[c]=++t);for(i=c=0;c<b;++c)j=f[c],j==="("?(++i,d[i]===void 0&&(f[c]="(?:")):"\\"===j.charAt(0)&&
+(j=+j.substring(1))&&j<=i&&(f[c]="\\"+d[i]);for(i=c=0;c<b;++c)"^"===f[c]&&"^"!==f[c+1]&&(f[c]="");if(a.ignoreCase&&s)for(c=0;c<b;++c)j=f[c],a=j.charAt(0),j.length>=2&&a==="["?f[c]=h(j):a!=="\\"&&(f[c]=j.replace(/[A-Za-z]/g,function(a){a=a.charCodeAt(0);return"["+String.fromCharCode(a&-33,a|32)+"]"}));return f.join("")}for(var t=0,s=!1,l=!1,p=0,d=a.length;p<d;++p){var g=a[p];if(g.ignoreCase)l=!0;else if(/[a-z]/i.test(g.source.replace(/\\u[\da-f]{4}|\\x[\da-f]{2}|\\[^UXux]/gi,""))){s=!0;l=!1;break}}for(var r=
+{b:8,t:9,n:10,v:11,f:12,r:13},n=[],p=0,d=a.length;p<d;++p){g=a[p];if(g.global||g.multiline)throw Error(""+g);n.push("(?:"+y(g)+")")}return RegExp(n.join("|"),l?"gi":"g")}function M(a){function m(a){switch(a.nodeType){case 1:if(e.test(a.className))break;for(var g=a.firstChild;g;g=g.nextSibling)m(g);g=a.nodeName;if("BR"===g||"LI"===g)h[s]="\n",t[s<<1]=y++,t[s++<<1|1]=a;break;case 3:case 4:g=a.nodeValue,g.length&&(g=p?g.replace(/\r\n?/g,"\n"):g.replace(/[\t\n\r ]+/g," "),h[s]=g,t[s<<1]=y,y+=g.length,
+t[s++<<1|1]=a)}}var e=/(?:^|\s)nocode(?:\s|$)/,h=[],y=0,t=[],s=0,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=document.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);m(a);return{a:h.join("").replace(/\n$/,""),c:t}}function B(a,m,e,h){m&&(a={a:m,d:a},e(a),h.push.apply(h,a.e))}function x(a,m){function e(a){for(var l=a.d,p=[l,"pln"],d=0,g=a.a.match(y)||[],r={},n=0,z=g.length;n<z;++n){var f=g[n],b=r[f],o=void 0,c;if(typeof b===
+"string")c=!1;else{var i=h[f.charAt(0)];if(i)o=f.match(i[1]),b=i[0];else{for(c=0;c<t;++c)if(i=m[c],o=f.match(i[1])){b=i[0];break}o||(b="pln")}if((c=b.length>=5&&"lang-"===b.substring(0,5))&&!(o&&typeof o[1]==="string"))c=!1,b="src";c||(r[f]=b)}i=d;d+=f.length;if(c){c=o[1];var j=f.indexOf(c),k=j+c.length;o[2]&&(k=f.length-o[2].length,j=k-c.length);b=b.substring(5);B(l+i,f.substring(0,j),e,p);B(l+i+j,c,C(b,c),p);B(l+i+k,f.substring(k),e,p)}else p.push(l+i,b)}a.e=p}var h={},y;(function(){for(var e=a.concat(m),
+l=[],p={},d=0,g=e.length;d<g;++d){var r=e[d],n=r[3];if(n)for(var k=n.length;--k>=0;)h[n.charAt(k)]=r;r=r[1];n=""+r;p.hasOwnProperty(n)||(l.push(r),p[n]=q)}l.push(/[\S\s]/);y=L(l)})();var t=m.length;return e}function u(a){var m=[],e=[];a.tripleQuotedStrings?m.push(["str",/^(?:'''(?:[^'\\]|\\[\S\s]|''?(?=[^']))*(?:'''|$)|"""(?:[^"\\]|\\[\S\s]|""?(?=[^"]))*(?:"""|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$))/,q,"'\""]):a.multiLineStrings?m.push(["str",/^(?:'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$)|`(?:[^\\`]|\\[\S\s])*(?:`|$))/,
+q,"'\"`"]):m.push(["str",/^(?:'(?:[^\n\r'\\]|\\.)*(?:'|$)|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,q,"\"'"]);a.verbatimStrings&&e.push(["str",/^@"(?:[^"]|"")*(?:"|$)/,q]);var h=a.hashComments;h&&(a.cStyleComments?(h>1?m.push(["com",/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,q,"#"]):m.push(["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,q,"#"]),e.push(["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,q])):m.push(["com",/^#[^\n\r]*/,
+q,"#"]));a.cStyleComments&&(e.push(["com",/^\/\/[^\n\r]*/,q]),e.push(["com",/^\/\*[\S\s]*?(?:\*\/|$)/,q]));a.regexLiterals&&e.push(["lang-regex",/^(?:^^\.?|[!+-]|!=|!==|#|%|%=|&|&&|&&=|&=|\(|\*|\*=|\+=|,|-=|->|\/|\/=|:|::|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|[?@[^]|\^=|\^\^|\^\^=|{|\||\|=|\|\||\|\|=|~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\s*(\/(?=[^*/])(?:[^/[\\]|\\[\S\s]|\[(?:[^\\\]]|\\[\S\s])*(?:]|$))+\/)/]);(h=a.types)&&e.push(["typ",h]);a=(""+a.keywords).replace(/^ | $/g,
+"");a.length&&e.push(["kwd",RegExp("^(?:"+a.replace(/[\s,]+/g,"|")+")\\b"),q]);m.push(["pln",/^\s+/,q," \r\n\t\xa0"]);e.push(["lit",/^@[$_a-z][\w$@]*/i,q],["typ",/^(?:[@_]?[A-Z]+[a-z][\w$@]*|\w+_t\b)/,q],["pln",/^[$_a-z][\w$@]*/i,q],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,q,"0123456789"],["pln",/^\\[\S\s]?/,q],["pun",/^.[^\s\w"-$'./@\\`]*/,q]);return x(m,e)}function D(a,m){function e(a){switch(a.nodeType){case 1:if(k.test(a.className))break;if("BR"===a.nodeName)h(a),
+a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)e(a);break;case 3:case 4:if(p){var b=a.nodeValue,d=b.match(t);if(d){var c=b.substring(0,d.index);a.nodeValue=c;(b=b.substring(d.index+d[0].length))&&a.parentNode.insertBefore(s.createTextNode(b),a.nextSibling);h(a);c||a.parentNode.removeChild(a)}}}}function h(a){function b(a,d){var e=d?a.cloneNode(!1):a,f=a.parentNode;if(f){var f=b(f,1),g=a.nextSibling;f.appendChild(e);for(var h=g;h;h=g)g=h.nextSibling,f.appendChild(h)}return e}
+for(;!a.nextSibling;)if(a=a.parentNode,!a)return;for(var a=b(a.nextSibling,0),e;(e=a.parentNode)&&e.nodeType===1;)a=e;d.push(a)}var k=/(?:^|\s)nocode(?:\s|$)/,t=/\r\n?|\n/,s=a.ownerDocument,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=s.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);for(l=s.createElement("LI");a.firstChild;)l.appendChild(a.firstChild);for(var d=[l],g=0;g<d.length;++g)e(d[g]);m===(m|0)&&d[0].setAttribute("value",
+m);var r=s.createElement("OL");r.className="linenums";for(var n=Math.max(0,m-1|0)||0,g=0,z=d.length;g<z;++g)l=d[g],l.className="L"+(g+n)%10,l.firstChild||l.appendChild(s.createTextNode("\xa0")),r.appendChild(l);a.appendChild(r)}function k(a,m){for(var e=m.length;--e>=0;){var h=m[e];A.hasOwnProperty(h)?window.console&&console.warn("cannot override language handler %s",h):A[h]=a}}function C(a,m){if(!a||!A.hasOwnProperty(a))a=/^\s*</.test(m)?"default-markup":"default-code";return A[a]}function E(a){var m=
+a.g;try{var e=M(a.h),h=e.a;a.a=h;a.c=e.c;a.d=0;C(m,h)(a);var k=/\bMSIE\b/.test(navigator.userAgent),m=/\n/g,t=a.a,s=t.length,e=0,l=a.c,p=l.length,h=0,d=a.e,g=d.length,a=0;d[g]=s;var r,n;for(n=r=0;n<g;)d[n]!==d[n+2]?(d[r++]=d[n++],d[r++]=d[n++]):n+=2;g=r;for(n=r=0;n<g;){for(var z=d[n],f=d[n+1],b=n+2;b+2<=g&&d[b+1]===f;)b+=2;d[r++]=z;d[r++]=f;n=b}for(d.length=r;h<p;){var o=l[h+2]||s,c=d[a+2]||s,b=Math.min(o,c),i=l[h+1],j;if(i.nodeType!==1&&(j=t.substring(e,b))){k&&(j=j.replace(m,"\r"));i.nodeValue=
+j;var u=i.ownerDocument,v=u.createElement("SPAN");v.className=d[a+1];var x=i.parentNode;x.replaceChild(v,i);v.appendChild(i);e<o&&(l[h+1]=i=u.createTextNode(t.substring(b,o)),x.insertBefore(i,v.nextSibling))}e=b;e>=o&&(h+=2);e>=c&&(a+=2)}}catch(w){"console"in window&&console.log(w&&w.stack?w.stack:w)}}var v=["break,continue,do,else,for,if,return,while"],w=[[v,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"],
+"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],F=[w,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],G=[w,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"],
+H=[G,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"],w=[w,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"],I=[v,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"],
+J=[v,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],v=[v,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],K=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/,N=/\S/,O=u({keywords:[F,H,w,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END"+
+I,J,v],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),A={};k(O,["default-code"]);k(x([],[["pln",/^[^<?]+/],["dec",/^<!\w[^>]*(?:>|$)/],["com",/^<\!--[\S\s]*?(?:--\>|$)/],["lang-",/^<\?([\S\s]+?)(?:\?>|$)/],["lang-",/^<%([\S\s]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",/^<xmp\b[^>]*>([\S\s]+?)<\/xmp\b[^>]*>/i],["lang-js",/^<script\b[^>]*>([\S\s]*?)(<\/script\b[^>]*>)/i],["lang-css",/^<style\b[^>]*>([\S\s]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),
+["default-markup","htm","html","mxml","xhtml","xml","xsl"]);k(x([["pln",/^\s+/,q," \t\r\n"],["atv",/^(?:"[^"]*"?|'[^']*'?)/,q,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^\s"'>]*(?:[^\s"'/>]|\/(?=\s)))/],["pun",/^[/<->]+/],["lang-js",/^on\w+\s*=\s*"([^"]+)"/i],["lang-js",/^on\w+\s*=\s*'([^']+)'/i],["lang-js",/^on\w+\s*=\s*([^\s"'>]+)/i],["lang-css",/^style\s*=\s*"([^"]+)"/i],["lang-css",/^style\s*=\s*'([^']+)'/i],["lang-css",
+/^style\s*=\s*([^\s"'>]+)/i]]),["in.tag"]);k(x([],[["atv",/^[\S\s]+/]]),["uq.val"]);k(u({keywords:F,hashComments:!0,cStyleComments:!0,types:K}),["c","cc","cpp","cxx","cyc","m"]);k(u({keywords:"null,true,false"}),["json"]);k(u({keywords:H,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:K}),["cs"]);k(u({keywords:G,cStyleComments:!0}),["java"]);k(u({keywords:v,hashComments:!0,multiLineStrings:!0}),["bsh","csh","sh"]);k(u({keywords:I,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}),
+["cv","py"]);k(u({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["perl","pl","pm"]);k(u({keywords:J,hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb"]);k(u({keywords:w,cStyleComments:!0,regexLiterals:!0}),["js"]);k(u({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes",
+hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);k(x([],[["str",/^[\S\s]+/]]),["regex"]);window.prettyPrintOne=function(a,m,e){var h=document.createElement("PRE");h.innerHTML=a;e&&D(h,e);E({g:m,i:e,h:h});return h.innerHTML};window.prettyPrint=function(a){function m(){for(var e=window.PR_SHOULD_USE_CONTINUATION?l.now()+250:Infinity;p<h.length&&l.now()<e;p++){var n=h[p],k=n.className;if(k.indexOf("prettyprint")>=0){var k=k.match(g),f,b;if(b=
+!k){b=n;for(var o=void 0,c=b.firstChild;c;c=c.nextSibling)var i=c.nodeType,o=i===1?o?b:c:i===3?N.test(c.nodeValue)?b:o:o;b=(f=o===b?void 0:o)&&"CODE"===f.tagName}b&&(k=f.className.match(g));k&&(k=k[1]);b=!1;for(o=n.parentNode;o;o=o.parentNode)if((o.tagName==="pre"||o.tagName==="code"||o.tagName==="xmp")&&o.className&&o.className.indexOf("prettyprint")>=0){b=!0;break}b||((b=(b=n.className.match(/\blinenums\b(?::(\d+))?/))?b[1]&&b[1].length?+b[1]:!0:!1)&&D(n,b),d={g:k,h:n,i:b},E(d))}}p<h.length?setTimeout(m,
+250):a&&a()}for(var e=[document.getElementsByTagName("pre"),document.getElementsByTagName("code"),document.getElementsByTagName("xmp")],h=[],k=0;k<e.length;++k)for(var t=0,s=e[k].length;t<s;++t)h.push(e[k][t]);var e=q,l=Date;l.now||(l={now:function(){return+new Date}});var p=0,d,g=/\blang(?:uage)?-([\w.]+)(?!\S)/;m()};window.PR={createSimpleLexer:x,registerLangHandler:k,sourceDecorator:u,PR_ATTRIB_NAME:"atn",PR_ATTRIB_VALUE:"atv",PR_COMMENT:"com",PR_DECLARATION:"dec",PR_KEYWORD:"kwd",PR_LITERAL:"lit",
+PR_NOCODE:"nocode",PR_PLAIN:"pln",PR_PUNCTUATION:"pun",PR_SOURCE:"src",PR_STRING:"str",PR_TAG:"tag",PR_TYPE:"typ"}})();
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Common.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Common.soy
new file mode 100644
index 0000000..4297d31
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Common.soy
@@ -0,0 +1,95 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Common header for Gitiles.
+ *
+ * @param title title for this page. Always suffixed with repository name and a
+ * sitewide title.
+ * @param? repositoryName repository name for this page, if applicable.
+ * @param? menuEntries optional list of menu entries with "text" and optional
+ * "url" keys.
+ * @param breadcrumbs navigation breadcrumbs for this page.
+ * @param? css optional list of CSS URLs to include.
+ * @param? js optional list of Javascript URLs to include.
+ * @param? onLoad optional Javascript to execute in the body's onLoad handler.
+ * Warning: not autoescaped.
+ */
+{template .header}
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
+<html>
+<head>
+ <title>
+ {$title}
+ {if $repositoryName}
+ {sp}- {$repositoryName}
+ {/if}
+ {sp}- {msg desc="name of the application"}Git at Google{/msg}
+ </title>
+ <link rel="stylesheet" type="text/css" href="//www.google.com/css/go.css" />
+
+ {if $css and length($css)}
+ {foreach $url in $css}
+ <link rel="stylesheet" type="text/css" href="{$url}" />
+ {/foreach}
+ {/if}
+ // Include default CSS after custom CSS so it can override defaults in third-
+ // party stylesheets (e.g. prettify).
+ <link rel="stylesheet" type="text/css" href="{gitiles.CSS_URL}" />
+
+ {if $js and length($js)}
+ {foreach $url in $js}
+ <script src="{$url}" type="text/javascript"></script>
+ {/foreach}
+ {/if}
+</head>
+<body {if $onLoad}onload="{$onLoad|id}"{/if}>
+ {call .customHeader /}
+
+ {if $menuEntries and length($menuEntries)}
+ <div class="menu">
+ {foreach $entry in $menuEntries}
+ {sp}
+ {if $entry.url}
+ <a href="{$entry.url}"{if not isLast($entry)} class="entry"{/if}>{$entry.text}</a>
+ {else}
+ <span{if not isLast($entry)} class="entry"{/if}>{$entry.text}</span>
+ {/if}
+ {/foreach}
+ {sp}
+ </div>
+ {/if}
+
+ {if $breadcrumbs and length($breadcrumbs)}
+ <div class="breadcrumbs">
+ {foreach $entry in $breadcrumbs}
+ {if not isFirst($entry)}{sp}/{sp}{/if}
+ {if not isLast($entry)}
+ <a href="{$entry.url}">{$entry.text}</a>
+ {else}
+ {$entry.text}
+ {/if}
+ {/foreach}
+ </div>
+ {/if}
+{/template}
+
+/**
+ * Standard footer.
+ */
+{template .footer}
+</body>
+</html>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DefaultCustomTemplates.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DefaultCustomTemplates.soy
new file mode 100644
index 0000000..a1f5d9e
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DefaultCustomTemplates.soy
@@ -0,0 +1,21 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Default custom header implementation for Gitiles.
+ */
+{template .customHeader}
+<h1>{msg desc="short name of the application"}Gitiles{/msg}</h1>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DiffDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DiffDetail.soy
new file mode 100644
index 0000000..dfd8af2
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/DiffDetail.soy
@@ -0,0 +1,49 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page showing diffs for a single commit.
+ *
+ * @param title human-readable revision name.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param? commit optional commit for which diffs are displayed, with keys
+ * corresponding to the gitiles.commitDetail template (minus "diffTree").
+ */
+{template .diffDetail}
+{call .header data="all" /}
+
+{if $commit}
+ {call .commitDetail data="$commit" /}
+{/if}
+<div id="DIFF_OUTPUT_BLOCK" />
+
+{call .footer /}
+{/template}
+
+/**
+ * File header for a single unified diff patch.
+ *
+ * @param first the first line of the header, with no trailing LF.
+ * @param rest remaining lines of the header, if any.
+ * @param fileIndex position of the file within the difference.
+ */
+{template .diffHeader}
+<pre class="diff-header">
+<a name="F{$fileIndex}" class="diff-git">{$first}</a>{\n}
+{$rest}
+</pre>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy
new file mode 100644
index 0000000..1ee9616
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/HostIndex.soy
@@ -0,0 +1,77 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * HTML page for /.
+ *
+ * @param hostName host name.
+ * @param? menuEntries menu entries.
+ * @param baseUrl base URL for repositories.
+ * @param repositories list of repository description maps with name, cloneUrl,
+ * and optional description values.
+ */
+{template .hostIndex}
+{call .header}
+ {param title: $hostName ? $hostName + ' Git repositories' : 'Git repositories' /}
+ {param menuEntries: $menuEntries /}
+ {param breadcrumbs: null /}
+{/call}
+
+{if length($repositories)}
+
+ <h2>
+ {msg desc="Git repositories available on the host"}
+ {$hostName} Git repositories
+ {/msg}
+ </h2>
+
+ <div class="instructions">
+ {msg desc="description on how to use this repository"}
+ To clone one of these repositories, install{sp}
+ <a href="http://www.git-scm.com/">git</a>, and run:
+ <pre>git clone {$baseUrl}<em>name</em></pre>
+ {/msg}
+ </div>
+
+ <table class="list">
+ <tr class="no-hover">
+ <th width="25%">
+ {msg desc="column header for repository name"}
+ Name
+ {/msg}
+ </th>
+ <th>
+ {msg desc="column header for repository description"}
+ Description
+ {/msg}
+ </th>
+ </tr>
+ {foreach $repo in $repositories}
+ <tr>
+ <td>
+ <a href="{$repo.url}">{$repo.name}</a>
+ </td>
+ <td>{$repo.description}</td>
+ </tr>
+ {/foreach}
+ </table>
+ <div class="footer">
+ <a href="?format=TEXT">{msg desc="text format"}TXT{/msg}</a>
+ {sp}
+ <a href="?format=JSON">{msg desc="JSON format"}JSON{/msg}</a>
+ </div>
+{/if}
+{call .footer /}
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy
new file mode 100644
index 0000000..b9ae9c7
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy
@@ -0,0 +1,95 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page showing a shortlog for a commit.
+ *
+ * @param title human-readable revision name.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param? tags optional list of tags encountered when peeling this object, with
+ * keys corresponding to gitiles.tagDetail.
+ * @param entries list of log entries; see .logEntry.
+ * @param? nextUrl URL for the next page of results.
+ * @param? previousUrl URL for the previous page of results.
+ */
+{template .logDetail}
+{call .header data="all" /}
+
+{if $tags}
+ {foreach $tag in $tags}
+ {call gitiles.tagDetail data="$tag" /}
+ {/foreach}
+{/if}
+
+{if $previousUrl}
+ <div class="log-nav">
+ <a href="{$previousUrl}">{msg desc="text for previous URL"}« Previous{/msg}</a>
+ </div>
+{/if}
+
+{if length($entries)}
+ <ol class="shortlog">
+ {foreach $entry in $entries}
+ <li{if $previousUrl and isFirst($entry)} class="first"{/if}>
+ {call .logEntry data="$entry" /}
+ </li>
+ {/foreach}
+ </ol>
+{else}
+ <p>{msg desc="informational text for when the log is empty"}No commits.{/msg}</p>
+{/if}
+
+{if $nextUrl}
+ <div class="log-nav">
+ <a href="{$nextUrl}">{msg desc="text for next URL"}Next »{/msg}</a>
+ </div>
+{/if}
+
+{call .footer /}
+{/template}
+
+/**
+ * Single shortlog entry.
+ *
+ * @param abbrevSha abbreviated SHA-1.
+ * @param url URL to commit detail page.
+ * @param shortMessage short commit message.
+ * @param author author information with at least "name" and "relativeTime" keys.
+ * @param branches list of branches for this entry, with "name" and "url" keys.
+ * @param tags list of tags for this entry, with "name" and "url" keys.
+ */
+{template .logEntry}
+<a href="{$url}">
+ <span class="sha1">{$abbrevSha}</span>
+ // nbsp instad of CSS padding/margin because those cause a break in the
+ // underline.
+
+ {sp}<span class="commit-message">{$shortMessage}</span>
+</a>
+{sp}<span class="author">{msg desc="commit author name"}by {$author.name}{/msg}</span>
+{sp}<span class="time">- {$author.relativeTime}</span>
+{if length($branches)}
+ {foreach $branch in $branches}
+ {sp}<a href="{$branch.url}" class="branch-label">{$branch.name}</a>
+ {/foreach}
+{/if}
+{if length($tags)}
+ {foreach $tag in $tags}
+ {sp}<a href="{$tag.url}" class="tag-label">{$tag.name}</a>
+ {/foreach}
+{/if}
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
new file mode 100644
index 0000000..badf43c
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
@@ -0,0 +1,294 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detailed listing of a commit.
+ *
+ * @param author map with "name", "email", and "time" keys for the commit author.
+ * @param committer map with "name", "email", and "time" keys for the committer.
+ * @param sha commit SHA-1.
+ * @param tree tree SHA-1.
+ * @param treeUrl tree URL.
+ * @param parents list of parent objects with the following keys:
+ * sha: SHA-1.
+ * url: URL to view the parent commit.
+ * diffUrl: URL to display diffs relative to this parent.
+ * @param message list of commit message parts, where each part contains:
+ * text: raw text of the part.
+ * url: optional URL that should be linked to from the part.
+ * @param diffTree list of changed tree entries with the following keys:
+ * changeType: string matching an org.eclipse.jgit.diff.DiffEntry.ChangeType
+ * constant.
+ * path: (new) path of the tree entry.
+ * oldPath: old path, only for renames and copies.
+ * url: URL to a detail page for the tree entry.
+ * diffUrl: URL to a diff page for the tree entry's diff in this commit.
+ * @param logUrl URL to a log page starting at this commit.
+ */
+{template .commitDetail}
+<div class="git-commit">
+ <table>
+ <tr>
+ <th>{msg desc="Header for commit SHA entry"}commit{/msg}</th>
+ <td class="sha">
+ {$sha}
+ <span class="log-link">
+ [<a href="{$logUrl}">{msg desc="text for the log link"}log{/msg}</a>]
+ </span>
+ </td>
+ <td>{sp}</td>
+ </tr>
+ <tr>
+ <th>{msg desc="Header for commit author"}author{/msg}</th>
+ <td>{call .person_ data="$author" /}</td>
+ <td>{$author.time}</td>
+ </tr>
+ <tr>
+ <th>{msg desc="Header for committer"}committer{/msg}</th>
+ <td>{call .person_ data="$committer" /}</td>
+ <td>{$committer.time}</td>
+ </tr>
+ <tr>
+ <th>{msg desc="Header for tree SHA entry"}tree{/msg}</th>
+ <td class="sha"><a href="{$treeUrl}">{$tree}</a></td>
+ </tr>
+ {foreach $parent in $parents}
+ <tr>
+ <th>{msg desc="Header for parent SHA"}parent{/msg}</th>
+ <td>
+ <a href="{$parent.url}">{$parent.sha}</a>
+ <span class="diff-link">
+ [<a href="{$parent.diffUrl}">{msg desc="text for the parent diff link"}diff{/msg}</a>]
+ </span>
+ </td>
+ </tr>
+ {/foreach}
+ </table>
+</div>
+{call .message_}
+ {param className: 'commit-message' /}
+ {param message: $message /}
+{/call}
+
+{if $diffTree and length($diffTree)}
+ <ul class="diff-tree">
+ {foreach $entry in $diffTree}
+ <li>
+ <a href="{$entry.url}">{$entry.path}</a>
+ {switch $entry.changeType}
+ {case 'ADD'}
+ <span class="add">
+ {msg desc="Text for a new tree entry"}
+ [Added - <a href="{$entry.diffUrl}">diff</a>]
+ {/msg}
+ </span>
+ {case 'MODIFY'}
+ <span class="modify">
+ {msg desc="Text for a modified tree entry"}
+ [<a href="{$entry.diffUrl}">diff</a>]
+ {/msg}
+ </span>
+ {case 'DELETE'}
+ <span class="delete">
+ {msg desc="Text for a deleted tree entry"}
+ [Deleted - <a href="{$entry.diffUrl}">diff</a>]
+ {/msg}
+ </span>
+ {case 'RENAME'}
+ <span class="rename">
+ {msg desc="Text for a renamed tree entry"}
+ [Renamed from {$entry.oldPath} - <a href="{$entry.diffUrl}">diff</a>]
+ {/msg}
+ </span>
+ {case 'COPY'}
+ <span class="copy">
+ {msg desc="Text for a copied tree entry"}
+ [Copied from {$entry.oldPath} - <a href="{$entry.diffUrl}">diff</a>]
+ {/msg}
+ </span>
+ {default}
+ {/switch}
+ </li>
+ {/foreach}
+ </ul>
+ <div class="diff-summary">
+ {if length($diffTree) == 1}
+ {msg desc="1 file changed"}1 file changed{/msg}
+ {else}
+ {msg desc="number of files changed"}{length($diffTree)} files changed{/msg}
+ {/if}
+ </div>
+{/if}
+
+{/template}
+
+/**
+ * Detailed listing of a tree.
+ *
+ * @param sha SHA of this path's tree.
+ * @param? logUrl optional URL to a log for this path.
+ * @param entries list of entries with the following keys:
+ * type: entry type, matching one of the constant names defined in
+ * org.eclipse.jgit.lib.FileMode.
+ * name: tree entry name.
+ * url: URL to link to.
+ * targetName: name of a symlink target, required only if type == 'SYMLINK'.
+ * targetUrl: optional url of a symlink target, required only if
+ * type == 'SYMLINK'.
+ */
+{template .treeDetail}
+<div class="sha1">
+ {msg desc="SHA-1 for the path's tree"}tree: {$sha}{/msg}
+ {if $logUrl}{sp}[<a href="{$logUrl}">{msg desc="history for a path"}path history{/msg}</a>]{/if}
+</div>
+
+{if length($entries)}
+ <ol class="list files">
+ {foreach $entry in $entries}
+ <li class="
+ {switch $entry.type}
+ {case 'TREE'}git-tree
+ {case 'SYMLINK'}symlink
+ {case 'REGULAR_FILE'}regular-file
+ {case 'EXECUTABLE_FILE'}executable-file
+ {case 'GITLINK'}gitlink
+ {default}regular-file
+ {/switch}
+ " title="
+ {switch $entry.type}
+ {case 'TREE'}{msg desc="Alt text for tree icon"}Tree{/msg}
+ {case 'SYMLINK'}{msg desc="Alt text for symlink icon"}Symlink{/msg}
+ {case 'REGULAR_FILE'}{msg desc="Alt text for regular file icon"}Regular file{/msg}
+ {case 'EXECUTABLE_FILE'}
+ {msg desc="Alt text for executable file icon"}Executable file{/msg}
+ {case 'GITLINK'}
+ {msg desc="Alt text for git submodule link icon"}Git submodule link{/msg}
+ {default}{msg desc="Alt text for other file icon"}Other{/msg}
+ {/switch}
+ - {$entry.name}">
+ <a href="{$entry.url}">{$entry.name}</a>
+ {if $entry.type == 'SYMLINK'}
+ {sp}⇨{sp}
+ {if $entry.targetUrl}
+ <a href="{$entry.targetUrl}">{$entry.targetName}</a>
+ {else}
+ {$entry.targetName}
+ {/if}
+ {/if}
+ // TODO(dborowitz): Something reasonable for gitlinks.
+ </li>
+ {/foreach}
+ </table>
+{else}
+ <p>{msg desc="Informational text for when a tree is empty"}This tree is empty.{/msg}</p>
+{/if}
+{/template}
+
+/**
+ * Detailed listing of a blob.
+ *
+ * @param sha SHA of this file's blob.
+ * @param? logUrl optional URL to a log for this file.
+ * @param data file data (may be empty), or null for a binary file.
+ * @param? lang prettyprint language extension for text file.
+ * @param? size for binary files only, size in bytes.
+ */
+{template .blobDetail}
+<div class="sha1">
+ {msg desc="SHA-1 for the file's blob"}blob: {$sha}{/msg}
+ {if $logUrl}{sp}[<a href="{$logUrl}">{msg desc="history for a file"}file history{/msg}</a>]{/if}
+</div>
+
+{if $data != null}
+ {if $data}
+ {if $lang != null}
+ <pre class="git-blob prettyprint linenums lang-{$lang}">{$data}</pre>
+ {else}
+ <pre class="git-blob">{$data}</pre>
+ {/if}
+ {else}
+ <div class="file-empty">Empty file</div>
+ {/if}
+{else}
+ <div class="file-binary">
+ {msg desc="size of binary file in bytes"}{$size}-byte binary file{/msg}
+ </div>
+{/if}
+{/template}
+
+/**
+ * Detailed listing of an annotated tag.
+ *
+ * @param sha SHA of this tag.
+ * @param? tagger optional map with "name", "email", and "time" keys for the
+ * tagger.
+ * @param object SHA of the object this tag points to.
+ * @param message tag message.
+ */
+{template .tagDetail}
+<div class="git-tag">
+ <table>
+ <tr>
+ <th>{msg desc="Header for tag SHA entry"}tag{/msg}</th>
+ <td class="sha">{$sha}</td>
+ <td>{sp}</td>
+ </tr>
+ {if $tagger}
+ <tr>
+ <th>{msg desc="Header for tagger"}tagger{/msg}</th>
+ <td>{call .person_ data="$tagger" /}</td>
+ <td>{$tagger.time}</td>
+ </tr>
+ {/if}
+ <tr>
+ <th>{msg desc="Header for tagged object SHA"}object{/msg}</th>
+ <td class="sha">{$object}</td>
+ <td>{sp}</td>
+ </tr>
+ </table>
+</div>
+{if $message and length($message)}
+ {call .message_}
+ {param className: 'tag-message' /}
+ {param message: $message /}
+ {/call}
+{/if}
+{/template}
+
+/**
+ * Line about a git person identity.
+ *
+ * @param name name.
+ * @param email email.
+ */
+{template .person_ private="true"}
+{$name}{if $email} <{$email}>{/if}
+{/template}
+
+/**
+ * Preformatted message, possibly containing hyperlinks.
+ *
+ * @param className CSS class name for <pre> block.
+ * @param message list of message parts, where each part contains:
+ * text: raw text of the part.
+ * url: optional URL that should be linked to from the part.
+ */
+{template .message_ private="true"}
+<pre class="{$className|id}">
+ {foreach $part in $message}
+ {if $part.url}<a href="{$part.url}">{$part.text}</a>{else}{$part.text}{/if}
+ {/foreach}
+</pre>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy
new file mode 100644
index 0000000..33d38a2
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/PathDetail.soy
@@ -0,0 +1,84 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page for a path within a tree.
+ *
+ * @param title human-readable name of this path.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param type path type, matching one of the constant names defined in
+ * org.eclipse.jgit.lib.FileMode.
+ * @param data path data, matching the params for one of .treeDetail,
+ * .blobDetail, .symlinkDetail, or .gitlinkDetail as appropriate.
+ */
+{template .pathDetail}
+{if $type == 'REGULAR_FILE' or $type == 'EXECUTABLE_FILE'}
+ {call .header}
+ {param title: $title /}
+ {param repositoryName: $repositoryName /}
+ {param menuEntries: $menuEntries /}
+ {param breadcrumbs: $breadcrumbs /}
+ {param css: [gitiles.PRETTIFY_CSS_URL] /}
+ {param js: [gitiles.PRETTIFY_JS_URL] /}
+ {param onLoad: 'prettyPrint()' /}
+ {/call}
+{else}
+ {call .header data="all" /}
+{/if}
+
+{switch $type}
+ {case 'TREE'}{call .treeDetail data="$data" /}
+ {case 'SYMLINK'}{call .symlinkDetail data="$data" /}
+ {case 'REGULAR_FILE'}{call .blobDetail data="$data" /}
+ {case 'EXECUTABLE_FILE'}{call .blobDetail data="$data" /}
+ {case 'GITLINK'}{call .gitlinkDetail data="$data" /}
+ {default}
+ <div class="error">
+ {msg desc="Error message for an unknown object type"}Object has unknown type.{/msg}
+ </div>
+{/switch}
+
+{call .footer /}
+{/template}
+
+/**
+ * Detail for a symbolic link.
+ *
+ * @param target target of this symlink.
+ * @param? targetUrl optional URL for the target, if it is within this repo.
+ */
+{template .symlinkDetail}
+<div class="symlink-detail">
+ {msg desc="Lead-in text for the symbolic link target."}Symbolic link to{/msg}
+ {sp}{if $targetUrl}<a href="{$targetUrl}">{$target}</a>{else}{$target}{/if}
+</div>
+{/template}
+
+/**
+ * Detail for a git submodule link.
+ *
+ * @param sha submodule commit SHA.
+ * @param remoteUrl URL of the remote repository.
+ * @param? httpUrl optional HTTP URL pointing to a web-browser-compatible URL of
+ * the remote repository.
+ */
+{template .gitlinkDetail}
+<div class="gitlink-detail">
+ {msg desc="Lead-in text for the git link URL"}Submodule link to {$sha} of{/msg}
+ {sp}{if $httpUrl}<a href="{$httpUrl}">{$remoteUrl}</a>{else}{$remoteUrl}{/if}
+</div>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
new file mode 100644
index 0000000..02bb4da
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
@@ -0,0 +1,79 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Index page for a repository.
+ *
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param cloneUrl clone URL for this repository.
+ * @param description description text of the repository.
+ * @param? branches list of branch objects with url and name keys.
+ * @param? tags list of tag objects with url and name keys.
+ */
+{template .repositoryIndex}
+{call .header}
+ {param title: $repositoryName /}
+ {param repositoryName: null /}
+ {param menuEntries: $menuEntries /}
+ {param breadcrumbs: $breadcrumbs /}
+{/call}
+
+{if $description}
+ <div class="repository-description">{$description}</div>
+{/if}
+
+<textarea rows="1" cols="150" class="clone-line"
+ onclick="this.focus();this.select();"
+ readonly="readonly">
+ git clone {$cloneUrl}
+</textarea>
+
+<div class="repository-refs">
+ {if $branches and length($branches)}
+ <div class="repository-branches">
+ <h3>Branches</h3>
+ <ul class="branch-list">
+ {foreach $branch in $branches}
+ {call .ref_ data="$branch" /}
+ {/foreach}
+ </ul>
+ </div>
+ {/if}
+
+ {if $tags and length($tags)}
+ <div class="repository-tags">
+ <h3>Tags</h3>
+ <ul class="branch-list">
+ {foreach $tag in $tags}
+ {call .ref_ data="$tag" /}
+ {/foreach}
+ </ul>
+ </div>
+ {/if}
+</div>
+{call .footer /}
+{/template}
+
+/**
+ * Detail for a single ref.
+ *
+ * @param url URL for ref detail page.
+ * @param name ref name.
+ */
+{template .ref_}
+<li><a href="{$url}">{$name}</a></li>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy
new file mode 100644
index 0000000..1abed6b
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RevisionDetail.soy
@@ -0,0 +1,63 @@
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+/**
+ * Detail page about a single revision.
+ *
+ * @param title human-readable revision name.
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param? hasBlob set to true if the revision or its peeled value is a blob.
+ * @param objects list of objects encountered when peeling this object. Each
+ * object has a "type" key with one of the
+ * org.eclipse.jgit.lib.Contants.TYPE_* constant strings, and a "data" key
+ * with an object whose keys correspond to the appropriate object detail
+ * template from ObjectDetail.soy.
+ */
+{template .revisionDetail}
+{if $hasBlob}
+ {call .header}
+ {param title: $title /}
+ {param repositoryName: $repositoryName /}
+ {param menuEntries: $menuEntries /}
+ {param breadcrumbs: $breadcrumbs /}
+ {param css: [gitiles.PRETTIFY_CSS_URL] /}
+ {param js: [gitiles.PRETTIFY_JS_URL] /}
+ {param onLoad: 'prettyPrint()' /}
+ {/call}
+{else}
+ {call .header data="all" /}
+{/if}
+
+{foreach $object in $objects}
+ {switch $object.type}
+ {case 'commit'}
+ {call .commitDetail data="$object.data" /}
+ {case 'tree'}
+ {call .treeDetail data="$object.data" /}
+ {case 'blob'}
+ {call .blobDetail data="$object.data" /}
+ {case 'tag'}
+ {call .tagDetail data="$object.data" /}
+ {default}
+ <div class="error">
+ {msg desc="Error message for an unknown object type"}Object has unknown type.{/msg}
+ </div>
+ {/switch}
+{/foreach}
+
+{call .footer /}
+{/template}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ConfigUtilTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ConfigUtilTest.java
new file mode 100644
index 0000000..96439c2
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ConfigUtilTest.java
@@ -0,0 +1,43 @@
+// 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.gitiles.ConfigUtil.getDuration;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.lib.Config;
+import org.joda.time.Duration;
+
+/** Tests for configuration utilities. */
+public class ConfigUtilTest extends TestCase {
+ public void testGetDuration() throws Exception {
+ Duration def = Duration.standardSeconds(2);
+ Config config = new Config();
+ Duration t;
+
+ config.setString("core", "dht", "timeout", "500 ms");
+ t = getDuration(config, "core", "dht", "timeout", def);
+ assertEquals(500, t.getMillis());
+
+ config.setString("core", "dht", "timeout", "5.2 sec");
+ t = getDuration(config, "core", "dht", "timeout", def);
+ assertEquals(5200, t.getMillis());
+
+ config.setString("core", "dht", "timeout", "1 min");
+ t = getDuration(config, "core", "dht", "timeout", def);
+ assertEquals(60000, t.getMillis());
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletRequest.java b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletRequest.java
new file mode 100644
index 0000000..c1afa74
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletRequest.java
@@ -0,0 +1,389 @@
+// 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.Charsets.UTF_8;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gitiles.TestGitilesUrls.URLS;
+
+import com.google.common.base.Function;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
+
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+
+import java.io.BufferedReader;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import javax.servlet.RequestDispatcher;
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+/** Simple fake implementation of {@link HttpServletRequest}. */
+public class FakeHttpServletRequest implements HttpServletRequest {
+ public static final String SERVLET_PATH = "/b";
+
+ public static FakeHttpServletRequest newRequest() {
+ return new FakeHttpServletRequest(
+ URLS.getHostName(null),
+ 80,
+ "",
+ SERVLET_PATH,
+ "");
+ }
+
+ public static FakeHttpServletRequest newRequest(DfsRepository repo) {
+ FakeHttpServletRequest req = newRequest();
+ req.setAttribute(ServletUtils.ATTRIBUTE_REPOSITORY, repo);
+ return req;
+ }
+
+ private final Map<String, Object> attributes;
+ private final ListMultimap<String, String> headers;
+
+ private ListMultimap<String, String> parameters;
+ private String hostName;
+ private int port;
+ private String contextPath;
+ private String servletPath;
+ private String path;
+
+ private FakeHttpServletRequest(String hostName, int port, String contextPath, String servletPath,
+ String path) {
+ this.hostName = checkNotNull(hostName, "hostName");
+ checkArgument(port > 0);
+ this.port = port;
+ this.contextPath = checkNotNull(contextPath, "contextPath");
+ this.servletPath = checkNotNull(servletPath, "servletPath");
+ attributes = Maps.newConcurrentMap();
+ parameters = LinkedListMultimap.create();
+ headers = LinkedListMultimap.create();
+ }
+
+ @Override
+ public Object getAttribute(String name) {
+ return attributes.get(name);
+ }
+
+ @Override
+ public Enumeration<String> getAttributeNames() {
+ return Collections.enumeration(attributes.keySet());
+ }
+
+ @Override
+ public String getCharacterEncoding() {
+ return UTF_8.name();
+ }
+
+ @Override
+ public int getContentLength() {
+ return -1;
+ }
+
+ @Override
+ public String getContentType() {
+ return null;
+ }
+
+ @Override
+ public ServletInputStream getInputStream() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getLocalAddr() {
+ return "1.2.3.4";
+ }
+
+ @Override
+ public String getLocalName() {
+ return hostName;
+ }
+
+ @Override
+ public int getLocalPort() {
+ return port;
+ }
+
+ @Override
+ public Locale getLocale() {
+ return Locale.US;
+ }
+
+ @Override
+ public Enumeration<Locale> getLocales() {
+ return Collections.enumeration(Collections.singleton(Locale.US));
+ }
+
+ @Override
+ public String getParameter(String name) {
+ return Iterables.getFirst(parameters.get(name), null);
+ }
+
+ private static final Function<Collection<String>, String[]> STRING_COLLECTION_TO_ARRAY =
+ new Function<Collection<String>, String[]>() {
+ @Override
+ public String[] apply(Collection<String> values) {
+ return values.toArray(new String[0]);
+ }
+ };
+
+ @Override
+ public Map<String, String[]> getParameterMap() {
+ return Collections.unmodifiableMap(
+ Maps.transformValues(parameters.asMap(), STRING_COLLECTION_TO_ARRAY));
+ }
+
+ @Override
+ public Enumeration<String> getParameterNames() {
+ return Collections.enumeration(parameters.keySet());
+ }
+
+ @Override
+ public String[] getParameterValues(String name) {
+ return STRING_COLLECTION_TO_ARRAY.apply(parameters.get(name));
+ }
+
+ public void setQueryString(String qs) {
+ ListMultimap<String, String> params = LinkedListMultimap.create();
+ for (String entry : Splitter.on('&').split(qs)) {
+ List<String> kv = ImmutableList.copyOf(Splitter.on('=').limit(2).split(entry));
+ try {
+ params.put(URLDecoder.decode(kv.get(0), UTF_8.name()),
+ kv.size() == 2 ? URLDecoder.decode(kv.get(1), UTF_8.name()) : "");
+ } catch (UnsupportedEncodingException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+ parameters = params;
+ }
+
+ @Override
+ public String getProtocol() {
+ return "HTTP/1.1";
+ }
+
+ @Override
+ public BufferedReader getReader() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ @Deprecated
+ public String getRealPath(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getRemoteAddr() {
+ return "5.6.7.8";
+ }
+
+ @Override
+ public String getRemoteHost() {
+ return "remotehost";
+ }
+
+ @Override
+ public int getRemotePort() {
+ return 1234;
+ }
+
+ @Override
+ public RequestDispatcher getRequestDispatcher(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getScheme() {
+ return port == 443 ? "https" : "http";
+ }
+
+ @Override
+ public String getServerName() {
+ return hostName;
+ }
+
+ @Override
+ public int getServerPort() {
+ return port;
+ }
+
+ @Override
+ public boolean isSecure() {
+ return port == 443;
+ }
+
+ @Override
+ public void removeAttribute(String name) {
+ attributes.remove(name);
+ }
+
+ @Override
+ public void setAttribute(String name, Object value) {
+ attributes.put(name, value);
+ }
+
+ @Override
+ public void setCharacterEncoding(String env) throws UnsupportedOperationException {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getAuthType() {
+ return null;
+ }
+
+ @Override
+ public String getContextPath() {
+ return contextPath;
+ }
+
+ @Override
+ public Cookie[] getCookies() {
+ return new Cookie[0];
+ }
+
+ @Override
+ public long getDateHeader(String name) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getHeader(String name) {
+ return Iterables.getFirst(headers.get(name), null);
+ }
+
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ return Collections.enumeration(headers.keySet());
+ }
+
+ @Override
+ public Enumeration<String> getHeaders(String name) {
+ return Collections.enumeration(headers.get(name));
+ }
+
+ @Override
+ public int getIntHeader(String name) {
+ return Integer.parseInt(getHeader(name));
+ }
+
+ @Override
+ public String getMethod() {
+ return "GET";
+ }
+
+ @Override
+ public String getPathInfo() {
+ return path;
+ }
+
+ public void setPathInfo(String path) {
+ this.path = checkNotNull(path);
+ }
+
+ @Override
+ public String getPathTranslated() {
+ return path;
+ }
+
+ @Override
+ public String getQueryString() {
+ return null;
+ }
+
+ @Override
+ public String getRemoteUser() {
+ return null;
+ }
+
+ @Override
+ public String getRequestURI() {
+ return null;
+ }
+
+ @Override
+ public StringBuffer getRequestURL() {
+ return null;
+ }
+
+ @Override
+ public String getRequestedSessionId() {
+ return null;
+ }
+
+ @Override
+ public String getServletPath() {
+ return servletPath;
+ }
+
+ @Override
+ public HttpSession getSession() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public HttpSession getSession(boolean create) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isRequestedSessionIdFromCookie() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isRequestedSessionIdFromURL() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ @Deprecated
+ public boolean isRequestedSessionIdFromUrl() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isRequestedSessionIdValid() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isUserInRole(String role) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletResponse.java b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletResponse.java
new file mode 100644
index 0000000..87a0099
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/FakeHttpServletResponse.java
@@ -0,0 +1,201 @@
+// 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.Charsets.UTF_8;
+
+import java.io.PrintWriter;
+import java.util.Locale;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+
+/** Simple fake implementation of {@link HttpServletResponse}. */
+public class FakeHttpServletResponse implements HttpServletResponse {
+
+ private volatile int status;
+
+ public FakeHttpServletResponse() {
+ status = 200;
+ }
+
+ @Override
+ public void flushBuffer() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int getBufferSize() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getCharacterEncoding() {
+ return UTF_8.name();
+ }
+
+ @Override
+ public String getContentType() {
+ return null;
+ }
+
+ @Override
+ public Locale getLocale() {
+ return Locale.US;
+ }
+
+ @Override
+ public ServletOutputStream getOutputStream() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public PrintWriter getWriter() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isCommitted() {
+ return false;
+ }
+
+ @Override
+ public void reset() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void resetBuffer() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setBufferSize(int sz) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setCharacterEncoding(String name) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setContentLength(int length) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setContentType(String type) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setLocale(Locale locale) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void addCookie(Cookie cookie) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void addDateHeader(String name, long value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void addIntHeader(String name, int value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean containsHeader(String name) {
+ return false;
+ }
+
+ @Override
+ public String encodeRedirectURL(String url) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ @Deprecated
+ public String encodeRedirectUrl(String url) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String encodeURL(String url) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ @Deprecated
+ public String encodeUrl(String url) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void sendError(int sc) {
+ status = sc;
+ }
+
+ @Override
+ public void sendError(int sc, String msg) {
+ status = sc;
+ }
+
+ @Override
+ public void sendRedirect(String msg) {
+ status = SC_FOUND;
+ }
+
+ @Override
+ public void setDateHeader(String name, long value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setHeader(String name, String value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setIntHeader(String name, int value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setStatus(int sc) {
+ status = sc;
+ }
+
+ @Override
+ @Deprecated
+ public void setStatus(int sc, String msg) {
+ status = sc;
+ }
+
+ public int getStatus() {
+ return status;
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesFilterTest.java
new file mode 100644
index 0000000..0d32dd4
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesFilterTest.java
@@ -0,0 +1,171 @@
+// 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.gitiles.GitilesFilter.REPO_PATH_REGEX;
+import static com.google.gitiles.GitilesFilter.REPO_REGEX;
+import static com.google.gitiles.GitilesFilter.ROOT_REGEX;
+
+import junit.framework.TestCase;
+
+import java.util.regex.Matcher;
+
+/** Tests for the Gitiles filter. */
+public class GitilesFilterTest extends TestCase {
+ public void testRootUrls() throws Exception {
+ assertFalse(ROOT_REGEX.matcher("").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo/").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo/ ").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo/+").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo/+").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo/ /").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo/+/").matches());
+ assertFalse(ROOT_REGEX.matcher("/foo/+/bar").matches());
+ Matcher m;
+
+ m = ROOT_REGEX.matcher("/");
+ assertTrue(m.matches());
+ assertEquals("/", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/", m.group(2));
+ assertEquals("", m.group(3));
+ assertEquals("", m.group(4));
+
+ m = ROOT_REGEX.matcher("//");
+ assertTrue(m.matches());
+ assertEquals("//", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/", m.group(2));
+ assertEquals("", m.group(3));
+ assertEquals("", m.group(4));
+ }
+
+ public void testRepoUrls() throws Exception {
+ assertFalse(REPO_REGEX.matcher("").matches());
+
+ // These match the regex but are served by the root regex binder, which is
+ // matched first.
+ assertTrue(REPO_REGEX.matcher("/").matches());
+ assertTrue(REPO_REGEX.matcher("//").matches());
+
+ assertFalse(REPO_REGEX.matcher("/foo/+").matches());
+ assertFalse(REPO_REGEX.matcher("/foo/bar/+").matches());
+ assertFalse(REPO_REGEX.matcher("/foo/bar/+/").matches());
+ assertFalse(REPO_REGEX.matcher("/foo/bar/+/baz").matches());
+ Matcher m;
+
+ m = REPO_REGEX.matcher("/foo");
+ assertTrue(m.matches());
+ assertEquals("/foo", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("", m.group(3));
+ assertEquals("", m.group(4));
+
+ m = REPO_REGEX.matcher("/foo/");
+ assertTrue(m.matches());
+ assertEquals("/foo/", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("", m.group(3));
+ assertEquals("", m.group(4));
+
+ m = REPO_REGEX.matcher("/foo/bar");
+ assertTrue(m.matches());
+ assertEquals("/foo/bar", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo/bar", m.group(2));
+ assertEquals("", m.group(3));
+ assertEquals("", m.group(4));
+
+ m = REPO_REGEX.matcher("/foo/bar+baz");
+ assertTrue(m.matches());
+ assertEquals("/foo/bar+baz", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo/bar+baz", m.group(2));
+ assertEquals("", m.group(3));
+ assertEquals("", m.group(4));
+ }
+
+ public void testRepoPathUrls() throws Exception {
+ assertFalse(REPO_PATH_REGEX.matcher("").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("/").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("//").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("/foo").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("/foo/ ").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("/foo/ /").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("/foo/ /bar").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("/foo/bar").matches());
+ assertFalse(REPO_PATH_REGEX.matcher("/foo/bar+baz").matches());
+ Matcher m;
+
+ m = REPO_PATH_REGEX.matcher("/foo/+");
+ assertTrue(m.matches());
+ assertEquals("/foo/+", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("+", m.group(3));
+ assertEquals("", m.group(4));
+
+ m = REPO_PATH_REGEX.matcher("/foo/+/");
+ assertTrue(m.matches());
+ assertEquals("/foo/+/", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("+", m.group(3));
+ assertEquals("/", m.group(4));
+
+ m = REPO_PATH_REGEX.matcher("/foo/+/bar/baz");
+ assertTrue(m.matches());
+ assertEquals("/foo/+/bar/baz", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("+", m.group(3));
+ assertEquals("/bar/baz", m.group(4));
+
+ m = REPO_PATH_REGEX.matcher("/foo/+/bar/baz/");
+ assertTrue(m.matches());
+ assertEquals("/foo/+/bar/baz/", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("+", m.group(3));
+ assertEquals("/bar/baz/", m.group(4));
+
+ m = REPO_PATH_REGEX.matcher("/foo/+/bar baz");
+ assertTrue(m.matches());
+ assertEquals("/foo/+/bar baz", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("+", m.group(3));
+ assertEquals("/bar baz", m.group(4));
+
+ m = REPO_PATH_REGEX.matcher("/foo/+/bar/+/baz");
+ assertTrue(m.matches());
+ assertEquals("/foo/+/bar/+/baz", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("+", m.group(3));
+ assertEquals("/bar/+/baz", m.group(4));
+
+ m = REPO_PATH_REGEX.matcher("/foo/+bar/baz");
+ assertTrue(m.matches());
+ assertEquals("/foo/+bar/baz", m.group(0));
+ assertEquals(m.group(0), m.group(1));
+ assertEquals("/foo", m.group(2));
+ assertEquals("+bar", m.group(3));
+ assertEquals("/baz", m.group(4));
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesUrlsTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesUrlsTest.java
new file mode 100644
index 0000000..4c2ff8b
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesUrlsTest.java
@@ -0,0 +1,48 @@
+// 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.gitiles.GitilesUrls.NAME_ESCAPER;
+
+import junit.framework.TestCase;
+
+/** Unit tests for {@link GitilesUrls}. */
+public class GitilesUrlsTest extends TestCase {
+ public void testNameEscaperEscapesAppropriateSpecialCharacters() throws Exception {
+ assertEquals("foo_bar", NAME_ESCAPER.apply("foo_bar"));
+ assertEquals("foo-bar", NAME_ESCAPER.apply("foo-bar"));
+ assertEquals("foo%25bar", NAME_ESCAPER.apply("foo%bar"));
+ assertEquals("foo%26bar", NAME_ESCAPER.apply("foo&bar"));
+ assertEquals("foo%28bar", NAME_ESCAPER.apply("foo(bar"));
+ assertEquals("foo%29bar", NAME_ESCAPER.apply("foo)bar"));
+ assertEquals("foo%3Abar", NAME_ESCAPER.apply("foo:bar"));
+ assertEquals("foo%3Bbar", NAME_ESCAPER.apply("foo;bar"));
+ assertEquals("foo%3Dbar", NAME_ESCAPER.apply("foo=bar"));
+ assertEquals("foo%3Fbar", NAME_ESCAPER.apply("foo?bar"));
+ assertEquals("foo%5Bbar", NAME_ESCAPER.apply("foo[bar"));
+ assertEquals("foo%5Dbar", NAME_ESCAPER.apply("foo]bar"));
+ assertEquals("foo%7Bbar", NAME_ESCAPER.apply("foo{bar"));
+ assertEquals("foo%7Dbar", NAME_ESCAPER.apply("foo}bar"));
+ }
+ public void testNameEscaperDoesNotEscapeSlashes() throws Exception {
+ assertEquals("foo/bar", NAME_ESCAPER.apply("foo/bar"));
+ }
+
+ public void testNameEscaperEscapesSpacesWithPercentInsteadOfPlus() throws Exception {
+ assertEquals("foo+bar", NAME_ESCAPER.apply("foo+bar"));
+ assertEquals("foo%20bar", NAME_ESCAPER.apply("foo bar"));
+ assertEquals("foo%2520bar", NAME_ESCAPER.apply("foo%20bar"));
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
new file mode 100644
index 0000000..a8a5c4f
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -0,0 +1,504 @@
+// 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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.gitiles.GitilesView.Type;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Tests for Gitiles views. */
+public class GitilesViewTest extends TestCase {
+ private static final GitilesView HOST = GitilesView.hostIndex()
+ .setServletPath("/b")
+ .setHostName("host")
+ .build();
+
+ public void testEmptyServletPath() throws Exception {
+ GitilesView view = GitilesView.hostIndex()
+ .setServletPath("")
+ .setHostName("host")
+ .build();
+ assertEquals("", view.getServletPath());
+ assertEquals(Type.HOST_INDEX, view.getType());
+ assertEquals("host", view.getHostName());
+ assertNull(view.getRepositoryName());
+ assertEquals(Revision.NULL, view.getRevision());
+ assertNull(view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/?format=HTML", view.toUrl());
+ assertEquals(ImmutableList.of(ImmutableMap.of("text", "host", "url", "/?format=HTML")),
+ view.getBreadcrumbs());
+ }
+
+ public void testHostIndex() throws Exception {
+ assertEquals("/b", HOST.getServletPath());
+ assertEquals(Type.HOST_INDEX, HOST.getType());
+ assertEquals("host", HOST.getHostName());
+ assertNull(HOST.getRepositoryName());
+ assertEquals(Revision.NULL, HOST.getRevision());
+ assertNull(HOST.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/?format=HTML", HOST.toUrl());
+ assertEquals(ImmutableList.of(ImmutableMap.of("text", "host", "url", "/b/?format=HTML")),
+ HOST.getBreadcrumbs());
+ }
+
+ public void testQueryParams() throws Exception {
+ GitilesView view = GitilesView.hostIndex().copyFrom(HOST)
+ .putParam("foo", "foovalue")
+ .putParam("bar", "barvalue")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.HOST_INDEX, view.getType());
+ assertEquals("host", view.getHostName());
+ assertNull(view.getRepositoryName());
+ assertEquals(Revision.NULL, view.getRevision());
+ assertNull(view.getTreePath());
+ assertEquals(
+ ImmutableListMultimap.of(
+ "foo", "foovalue",
+ "bar", "barvalue"),
+ view.getParameters());
+
+ assertEquals("/b/?format=HTML&foo=foovalue&bar=barvalue", view.toUrl());
+ assertEquals(ImmutableList.of(ImmutableMap.of("text", "host", "url", "/b/?format=HTML")),
+ view.getBreadcrumbs());
+ }
+
+ public void testQueryParamsNotCopied() throws Exception {
+ GitilesView view = GitilesView.hostIndex().copyFrom(HOST)
+ .putParam("foo", "foovalue")
+ .putParam("bar", "barvalue")
+ .build();
+ GitilesView copy = GitilesView.hostIndex().copyFrom(view).build();
+ assertFalse(view.getParameters().isEmpty());
+ assertTrue(copy.getParameters().isEmpty());
+ }
+
+ public void testRepositoryIndex() throws Exception {
+ GitilesView view = GitilesView.repositoryIndex()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.REPOSITORY_INDEX, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(Revision.NULL, view.getRevision());
+ assertNull(view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/")),
+ view.getBreadcrumbs());
+ }
+
+ public void testRefWithRevision() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.revision()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertNull(view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+show/master", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master", "/b/foo/bar/+show/master")),
+ view.getBreadcrumbs());
+ }
+
+ public void testNoPathComponents() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.path()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setTreePath("/")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.PATH, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals("", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+/master/", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master", "/b/foo/bar/+show/master"),
+ breadcrumb(".", "/b/foo/bar/+/master/")),
+ view.getBreadcrumbs());
+ }
+
+ public void testOnePathComponent() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.path()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setTreePath("/file")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.PATH, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals("file", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+/master/file", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master", "/b/foo/bar/+show/master"),
+ breadcrumb(".", "/b/foo/bar/+/master/"),
+ breadcrumb("file", "/b/foo/bar/+/master/file")),
+ view.getBreadcrumbs());
+ }
+
+ public void testMultiplePathComponents() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.path()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setTreePath("/path/to/a/file")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.PATH, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals("path/to/a/file", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+/master/path/to/a/file", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master", "/b/foo/bar/+show/master"),
+ breadcrumb(".", "/b/foo/bar/+/master/"),
+ breadcrumb("path", "/b/foo/bar/+/master/path"),
+ breadcrumb("to", "/b/foo/bar/+/master/path/to"),
+ breadcrumb("a", "/b/foo/bar/+/master/path/to/a"),
+ breadcrumb("file", "/b/foo/bar/+/master/path/to/a/file")),
+ view.getBreadcrumbs());
+ }
+
+ public void testDiffAgainstFirstParent() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ ObjectId parent = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+ GitilesView view = GitilesView.diff()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setOldRevision(Revision.unpeeled("master^", parent))
+ .setTreePath("/path/to/a/file")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals("path/to/a/file", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+/master%5E%21/path/to/a/file", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master^!", "/b/foo/bar/+/master%5E%21/"),
+ breadcrumb(".", "/b/foo/bar/+/master%5E%21/"),
+ breadcrumb("path", "/b/foo/bar/+/master%5E%21/path"),
+ breadcrumb("to", "/b/foo/bar/+/master%5E%21/path/to"),
+ breadcrumb("a", "/b/foo/bar/+/master%5E%21/path/to/a"),
+ breadcrumb("file", "/b/foo/bar/+/master%5E%21/path/to/a/file")),
+ view.getBreadcrumbs());
+ }
+
+ public void testDiffAgainstEmptyRevision() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.diff()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setTreePath("/path/to/a/file")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertEquals("path/to/a/file", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+/master%5E%21/path/to/a/file", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master^!", "/b/foo/bar/+/master%5E%21/"),
+ breadcrumb(".", "/b/foo/bar/+/master%5E%21/"),
+ breadcrumb("path", "/b/foo/bar/+/master%5E%21/path"),
+ breadcrumb("to", "/b/foo/bar/+/master%5E%21/path/to"),
+ breadcrumb("a", "/b/foo/bar/+/master%5E%21/path/to/a"),
+ breadcrumb("file", "/b/foo/bar/+/master%5E%21/path/to/a/file")),
+ view.getBreadcrumbs());
+ }
+
+ public void testDiffAgainstOther() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ ObjectId other = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+ GitilesView view = GitilesView.diff()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setOldRevision(Revision.unpeeled("efab5678", other))
+ .setTreePath("/path/to/a/file")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals("efab5678", view.getOldRevision().getName());
+ assertEquals("path/to/a/file", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+/efab5678..master/path/to/a/file", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("efab5678..master", "/b/foo/bar/+/efab5678..master/"),
+ breadcrumb(".", "/b/foo/bar/+/efab5678..master/"),
+ breadcrumb("path", "/b/foo/bar/+/efab5678..master/path"),
+ breadcrumb("to", "/b/foo/bar/+/efab5678..master/path/to"),
+ breadcrumb("a", "/b/foo/bar/+/efab5678..master/path/to/a"),
+ breadcrumb("file", "/b/foo/bar/+/efab5678..master/path/to/a/file")),
+ view.getBreadcrumbs());
+ }
+
+ public void testBranchLogWithoutPath() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.log()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertNull(view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+/master", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master", "/b/foo/bar/+/master")),
+ view.getBreadcrumbs());
+ }
+
+ public void testIdLogWithoutPath() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.log()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("abcd1234", id))
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("abcd1234", view.getRevision().getName());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertNull(view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+log/abcd1234", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("abcd1234", "/b/foo/bar/+log/abcd1234")),
+ view.getBreadcrumbs());
+ }
+
+ public void testLogWithoutOldRevision() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.log()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setTreePath("/path/to/a/file")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertEquals("path/to/a/file", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+log/master/path/to/a/file", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master", "/b/foo/bar/+/master"),
+ breadcrumb("path", "/b/foo/bar/+log/master/path"),
+ breadcrumb("to", "/b/foo/bar/+log/master/path/to"),
+ breadcrumb("a", "/b/foo/bar/+log/master/path/to/a"),
+ breadcrumb("file", "/b/foo/bar/+log/master/path/to/a/file")),
+ view.getBreadcrumbs());
+ }
+
+ public void testLogWithOldRevision() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ ObjectId parent = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+ GitilesView view = GitilesView.log()
+ .copyFrom(HOST)
+ .setRepositoryName("foo/bar")
+ .setRevision(Revision.unpeeled("master", id))
+ .setOldRevision(Revision.unpeeled("master^", parent))
+ .setTreePath("/path/to/a/file")
+ .build();
+
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo/bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals("path/to/a/file", view.getTreePath());
+ assertTrue(HOST.getParameters().isEmpty());
+
+ assertEquals("/b/foo/bar/+log/master%5E..master/path/to/a/file", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo/bar", "/b/foo/bar/"),
+ breadcrumb("master^..master", "/b/foo/bar/+log/master%5E..master"),
+ breadcrumb("path", "/b/foo/bar/+log/master%5E..master/path"),
+ breadcrumb("to", "/b/foo/bar/+log/master%5E..master/path/to"),
+ breadcrumb("a", "/b/foo/bar/+log/master%5E..master/path/to/a"),
+ breadcrumb("file", "/b/foo/bar/+log/master%5E..master/path/to/a/file")),
+ view.getBreadcrumbs());
+ }
+
+ public void testEscaping() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ ObjectId parent = ObjectId.fromString("efab5678efab5678efab5678efab5678efab5678");
+ // Some of these values are not valid for Git, but check them anyway.
+ GitilesView view = GitilesView.log()
+ .copyFrom(HOST)
+ .setRepositoryName("foo?bar")
+ .setRevision(Revision.unpeeled("ba/d#name", id))
+ .setOldRevision(Revision.unpeeled("other\"na/me", parent))
+ .setTreePath("we ird/pa'th/name")
+ .putParam("k e y", "val/ue")
+ .setAnchor("anc#hor")
+ .build();
+
+ // Fields returned by getters are not escaped.
+ assertEquals("/b", view.getServletPath());
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("host", view.getHostName());
+ assertEquals("foo?bar", view.getRepositoryName());
+ assertEquals(id, view.getRevision().getId());
+ assertEquals("ba/d#name", view.getRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("other\"na/me", view.getOldRevision().getName());
+ assertEquals("we ird/pa'th/name", view.getTreePath());
+ assertEquals(ImmutableListMultimap.<String, String> of("k e y", "val/ue"),
+ view.getParameters());
+
+ assertEquals(
+ "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird/pa%27th/name"
+ + "?k+e+y=val%2Fue#anc%23hor", view.toUrl());
+ assertEquals(
+ ImmutableList.of(
+ // Names are not escaped (auto-escaped by Soy) but values are.
+ breadcrumb("host", "/b/?format=HTML"),
+ breadcrumb("foo?bar", "/b/foo%3Fbar/"),
+ breadcrumb("other\"na/me..ba/d#name", "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name"),
+ breadcrumb("we ird", "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird"),
+ breadcrumb("pa'th", "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird/pa%27th"),
+ breadcrumb("name",
+ "/b/foo%3Fbar/+log/other%22na/me..ba/d%23name/we%20ird/pa%27th/name")),
+ view.getBreadcrumbs());
+ }
+
+ private static ImmutableMap<String, String> breadcrumb(String text, String url) {
+ return ImmutableMap.of("text", text, "url", url);
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java
new file mode 100644
index 0000000..6f6caf2
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java
@@ -0,0 +1,113 @@
+// 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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import junit.framework.TestCase;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Tests for {@link Linkifier}. */
+public class LinkifierTest extends TestCase {
+ private static final HttpServletRequest REQ = FakeHttpServletRequest.newRequest();
+
+ @Override
+ protected void setUp() throws Exception {
+ }
+
+ public void testlinkifyMessageNoMatch() throws Exception {
+ Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+ assertEquals(ImmutableList.of(ImmutableMap.of("text", "some message text")),
+ l.linkify(FakeHttpServletRequest.newRequest(), "some message text"));
+ }
+
+ public void testlinkifyMessageUrl() throws Exception {
+ Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "http://my/url", "url", "http://my/url")),
+ l.linkify(REQ, "http://my/url"));
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "https://my/url", "url", "https://my/url")),
+ l.linkify(REQ, "https://my/url"));
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "foo "),
+ ImmutableMap.of("text", "http://my/url", "url", "http://my/url"),
+ ImmutableMap.of("text", " bar")),
+ l.linkify(REQ, "foo http://my/url bar"));
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "foo "),
+ ImmutableMap.of("text", "http://my/url", "url", "http://my/url"),
+ ImmutableMap.of("text", " bar "),
+ ImmutableMap.of("text", "http://my/other/url", "url", "http://my/other/url"),
+ ImmutableMap.of("text", " baz")),
+ l.linkify(REQ, "foo http://my/url bar http://my/other/url baz"));
+ }
+
+ public void testlinkifyMessageChangeIdNoGerrit() throws Exception {
+ Linkifier l = new Linkifier(new GitilesUrls() {
+ @Override
+ public String getBaseGerritUrl(HttpServletRequest req) {
+ return null;
+ }
+
+ @Override
+ public String getHostName(HttpServletRequest req) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getBaseGitUrl(HttpServletRequest req) {
+ throw new UnsupportedOperationException();
+ }
+ });
+ assertEquals(ImmutableList.of(ImmutableMap.of("text", "I0123456789")),
+ l.linkify(REQ, "I0123456789"));
+ assertEquals(ImmutableList.of(ImmutableMap.of("text", "Change-Id: I0123456789")),
+ l.linkify(REQ, "Change-Id: I0123456789"));
+ assertEquals(ImmutableList.of(ImmutableMap.of("text", "Change-Id: I0123456789 does not exist")),
+ l.linkify(REQ, "Change-Id: I0123456789 does not exist"));
+ }
+
+ public void testlinkifyMessageChangeId() throws Exception {
+ Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "I0123456789",
+ "url", "http://test-host-review/foo/#/q/I0123456789,n,z")),
+ l.linkify(REQ, "I0123456789"));
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "Change-Id: "),
+ ImmutableMap.of("text", "I0123456789",
+ "url", "http://test-host-review/foo/#/q/I0123456789,n,z")),
+ l.linkify(REQ, "Change-Id: I0123456789"));
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "Change-Id: "),
+ ImmutableMap.of("text", "I0123456789",
+ "url", "http://test-host-review/foo/#/q/I0123456789,n,z"),
+ ImmutableMap.of("text", " exists")),
+ l.linkify(REQ, "Change-Id: I0123456789 exists"));
+ }
+
+ public void testlinkifyMessageUrlAndChangeId() throws Exception {
+ Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+ assertEquals(ImmutableList.of(
+ ImmutableMap.of("text", "http://my/url/I0123456789", "url", "http://my/url/I0123456789"),
+ ImmutableMap.of("text", " is not change "),
+ ImmutableMap.of("text", "I0123456789",
+ "url", "http://test-host-review/foo/#/q/I0123456789,n,z")),
+ l.linkify(REQ, "http://my/url/I0123456789 is not change I0123456789"));
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/PaginatorTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/PaginatorTest.java
new file mode 100644
index 0000000..09661f2
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/PaginatorTest.java
@@ -0,0 +1,155 @@
+// 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.checkArgument;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+import java.util.List;
+
+/** Unit tests for {@link LogServlet}. */
+public class PaginatorTest extends TestCase {
+ private TestRepository<DfsRepository> repo;
+ private RevWalk walk;
+
+ @Override
+ protected void setUp() throws Exception {
+ repo = new TestRepository<DfsRepository>(
+ new InMemoryRepository(new DfsRepositoryDescription("test")));
+ walk = new RevWalk(repo.getRepository());
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ walk.release();
+ }
+
+ public void testStart() throws Exception {
+ List<RevCommit> commits = linearCommits(10);
+ walk.markStart(commits.get(9));
+ Paginator p = new Paginator(walk, 3, commits.get(9));
+ assertEquals(
+ ImmutableList.of(
+ commits.get(9),
+ commits.get(8),
+ commits.get(7)),
+ ImmutableList.copyOf(p));
+ assertNull(p.getPreviousStart());
+ assertEquals(commits.get(6), p.getNextStart());
+ }
+
+ public void testNoStartCommit() throws Exception {
+ List<RevCommit> commits = linearCommits(10);
+ walk.markStart(commits.get(9));
+ Paginator p = new Paginator(walk, 3, null);
+ assertEquals(
+ ImmutableList.of(
+ commits.get(9),
+ commits.get(8),
+ commits.get(7)),
+ ImmutableList.copyOf(p));
+ assertNull(p.getPreviousStart());
+ assertEquals(commits.get(6), p.getNextStart());
+ }
+
+ public void testLessThanOnePageIn() throws Exception {
+ List<RevCommit> commits = linearCommits(10);
+ walk.markStart(commits.get(9));
+ Paginator p = new Paginator(walk, 3, commits.get(8));
+ assertEquals(
+ ImmutableList.of(
+ commits.get(8),
+ commits.get(7),
+ commits.get(6)),
+ ImmutableList.copyOf(p));
+ assertEquals(commits.get(9), p.getPreviousStart());
+ assertEquals(commits.get(5), p.getNextStart());
+ }
+
+ public void testAtLeastOnePageIn() throws Exception {
+ List<RevCommit> commits = linearCommits(10);
+ walk.markStart(commits.get(9));
+ Paginator p = new Paginator(walk, 3, commits.get(7));
+ assertEquals(
+ ImmutableList.of(
+ commits.get(7),
+ commits.get(6),
+ commits.get(5)),
+ ImmutableList.copyOf(p));
+ assertEquals(commits.get(9), p.getPreviousStart());
+ assertEquals(commits.get(4), p.getNextStart());
+ }
+
+ public void testEnd() throws Exception {
+ List<RevCommit> commits = linearCommits(10);
+ walk.markStart(commits.get(9));
+ Paginator p = new Paginator(walk, 3, commits.get(2));
+ assertEquals(
+ ImmutableList.of(
+ commits.get(2),
+ commits.get(1),
+ commits.get(0)),
+ ImmutableList.copyOf(p));
+ assertEquals(commits.get(5), p.getPreviousStart());
+ assertNull(p.getNextStart());
+ }
+
+ public void testOnePastEnd() throws Exception {
+ List<RevCommit> commits = linearCommits(10);
+ walk.markStart(commits.get(9));
+ Paginator p = new Paginator(walk, 3, commits.get(1));
+ assertEquals(
+ ImmutableList.of(
+ commits.get(1),
+ commits.get(0)),
+ ImmutableList.copyOf(p));
+ assertEquals(commits.get(4), p.getPreviousStart());
+ assertNull(p.getNextStart());
+ }
+
+ public void testManyPastEnd() throws Exception {
+ List<RevCommit> commits = linearCommits(10);
+ walk.markStart(commits.get(9));
+ Paginator p = new Paginator(walk, 5, commits.get(1));
+ assertEquals(
+ ImmutableList.of(
+ commits.get(1),
+ commits.get(0)),
+ ImmutableList.copyOf(p));
+ assertEquals(commits.get(6), p.getPreviousStart());
+ assertNull(p.getNextStart());
+ }
+
+ private List<RevCommit> linearCommits(int n) throws Exception {
+ checkArgument(n > 0);
+ List<RevCommit> commits = Lists.newArrayList();
+ commits.add(repo.commit().create());
+ for (int i = 1; i < 10; i++) {
+ commits.add(repo.commit().parent(commits.get(commits.size() - 1)).create());
+ }
+ return commits;
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/RepositoryIndexServletTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/RepositoryIndexServletTest.java
new file mode 100644
index 0000000..3071f70
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/RepositoryIndexServletTest.java
@@ -0,0 +1,132 @@
+// 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.gitiles.TestGitilesUrls.URLS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Tests for {@link RepositoryIndexServlet}. */
+public class RepositoryIndexServletTest extends TestCase {
+ private TestRepository<DfsRepository> repo;
+ private RepositoryIndexServlet servlet;
+
+ @Override
+ protected void setUp() throws Exception {
+ repo = new TestRepository<DfsRepository>(
+ new InMemoryRepository(new DfsRepositoryDescription("test")));
+ servlet = new RepositoryIndexServlet(
+ new DefaultRenderer(),
+ new TestGitilesAccess(repo.getRepository()));
+ }
+
+ public void testEmpty() throws Exception {
+ Map<String, ?> data = buildData();
+ assertEquals(ImmutableList.of(), data.get("branches"));
+ assertEquals(ImmutableList.of(), data.get("tags"));
+ }
+
+ public void testBranchesAndTags() throws Exception {
+ repo.branch("refs/heads/foo").commit().create();
+ repo.branch("refs/heads/bar").commit().create();
+ repo.branch("refs/tags/baz").commit().create();
+ repo.branch("refs/nope/quux").commit().create();
+ Map<String, ?> data = buildData();
+
+ assertEquals(
+ ImmutableList.of(
+ ref("/b/test/+/bar", "bar"),
+ ref("/b/test/+/foo", "foo")),
+ data.get("branches"));
+ assertEquals(
+ ImmutableList.of(
+ ref("/b/test/+/baz", "baz")),
+ data.get("tags"));
+ }
+
+ public void testAmbiguousBranch() throws Exception {
+ repo.branch("refs/heads/foo").commit().create();
+ repo.branch("refs/heads/bar").commit().create();
+ repo.branch("refs/tags/foo").commit().create();
+ Map<String, ?> data = buildData();
+
+ assertEquals(
+ ImmutableList.of(
+ ref("/b/test/+/bar", "bar"),
+ ref("/b/test/+/refs/heads/foo", "foo")),
+ data.get("branches"));
+ assertEquals(
+ ImmutableList.of(
+ // refs/tags/ is searched before refs/heads/, so this does not
+ // appear ambiguous.
+ ref("/b/test/+/foo", "foo")),
+ data.get("tags"));
+ }
+
+ public void testAmbiguousRelativeToNonBranchOrTag() throws Exception {
+ repo.branch("refs/foo").commit().create();
+ repo.branch("refs/heads/foo").commit().create();
+ repo.branch("refs/tags/foo").commit().create();
+ Map<String, ?> data = buildData();
+
+ assertEquals(
+ ImmutableList.of(
+ ref("/b/test/+/refs/heads/foo", "foo")),
+ data.get("branches"));
+ assertEquals(
+ ImmutableList.of(
+ ref("/b/test/+/refs/tags/foo", "foo")),
+ data.get("tags"));
+ }
+
+ public void testRefsHeads() throws Exception {
+ repo.branch("refs/heads/foo").commit().create();
+ repo.branch("refs/heads/refs/heads/foo").commit().create();
+ Map<String, ?> data = buildData();
+
+ assertEquals(
+ ImmutableList.of(
+ ref("/b/test/+/foo", "foo"),
+ ref("/b/test/+/refs/heads/refs/heads/foo", "refs/heads/foo")),
+ data.get("branches"));
+ }
+
+ private Map<String, ?> buildData() throws IOException {
+ HttpServletRequest req = FakeHttpServletRequest.newRequest(repo.getRepository());
+ ViewFilter.setView(req, GitilesView.repositoryIndex()
+ .setHostName(URLS.getHostName(req))
+ .setServletPath(req.getServletPath())
+ .setRepositoryName("test")
+ .build());
+ return servlet.buildData(req);
+ }
+
+ private Map<String, String> ref(String url, String name) {
+ return ImmutableMap.of("url", url, "name", name);
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/RevisionParserTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/RevisionParserTest.java
new file mode 100644
index 0000000..23033e0
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/RevisionParserTest.java
@@ -0,0 +1,273 @@
+// 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 org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
+import static org.eclipse.jgit.lib.Constants.OBJ_TAG;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.gitiles.RevisionParser.Result;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevBlob;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+/** Tests for the revision parser. */
+public class RevisionParserTest extends TestCase {
+ private TestRepository<DfsRepository> repo;
+ private RevisionParser parser;
+
+ @Override
+ protected void setUp() throws Exception {
+ repo = new TestRepository<DfsRepository>(
+ new InMemoryRepository(new DfsRepositoryDescription("test")));
+ parser = new RevisionParser(
+ repo.getRepository(),
+ new TestGitilesAccess(repo.getRepository()).forRequest(null),
+ new VisibilityCache(false, CacheBuilder.newBuilder().maximumSize(0)));
+ }
+
+ public void testParseRef() throws Exception {
+ RevCommit master = repo.branch("refs/heads/master").commit().create();
+ assertEquals(new Result(Revision.peeled("master", master)),
+ parser.parse("master"));
+ assertEquals(new Result(Revision.peeled("refs/heads/master", master)),
+ parser.parse("refs/heads/master"));
+ assertNull(parser.parse("refs//heads//master"));
+ }
+
+ public void testParseRefParentExpression() throws Exception {
+ RevCommit root = repo.commit().create();
+ RevCommit parent1 = repo.commit().parent(root).create();
+ RevCommit parent2 = repo.commit().parent(root).create();
+ RevCommit merge = repo.branch("master").commit()
+ .parent(parent1)
+ .parent(parent2)
+ .create();
+ assertEquals(new Result(Revision.peeled("master", merge)), parser.parse("master"));
+ assertEquals(new Result(Revision.peeled("master^", parent1)), parser.parse("master^"));
+ assertEquals(new Result(Revision.peeled("master~1", parent1)), parser.parse("master~1"));
+ assertEquals(new Result(Revision.peeled("master^2", parent2)), parser.parse("master^2"));
+ assertEquals(new Result(Revision.peeled("master~2", root)), parser.parse("master~2"));
+ }
+
+ public void testParseCommitShaVisibleFromHead() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit commit = repo.branch("master").commit().parent(parent).create();
+ assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+ assertEquals(new Result(Revision.peeled(parent.name(), parent)), parser.parse(parent.name()));
+
+ String abbrev = commit.name().substring(0, 6);
+ assertEquals(new Result(Revision.peeled(abbrev, commit)), parser.parse(abbrev));
+ }
+
+ public void testParseCommitShaVisibleFromTag() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit commit = repo.commit().parent(parent).create();
+ repo.branch("master").commit().create();
+ repo.update("refs/tags/tag", repo.tag("tag", commit));
+
+ assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+ assertEquals(new Result(Revision.peeled(parent.name(), parent)), parser.parse(parent.name()));
+ }
+
+ public void testParseCommitShaVisibleFromOther() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit commit = repo.commit().parent(parent).create();
+ repo.branch("master").commit().create();
+ repo.update("refs/tags/tag", repo.tag("tag", repo.commit().create()));
+ repo.update("refs/meta/config", commit);
+
+ assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+ assertEquals(new Result(Revision.peeled(parent.name(), parent)), parser.parse(parent.name()));
+ }
+
+ public void testParseCommitShaVisibleFromChange() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit commit = repo.commit().parent(parent).create();
+ repo.branch("master").commit().create();
+ repo.update("refs/changes/01/0001", commit);
+
+ // Matches exactly.
+ assertEquals(new Result(Revision.peeled(commit.name(), commit)), parser.parse(commit.name()));
+ // refs/changes/* is excluded from ancestry search.
+ assertEquals(null, parser.parse(parent.name()));
+ }
+
+ public void testParseNonVisibleCommitSha() throws Exception {
+ RevCommit other = repo.commit().create();
+ RevCommit master = repo.branch("master").commit().create();
+ assertEquals(null, parser.parse(other.name()));
+
+ repo.branch("other").update(other);
+ assertEquals(new Result(Revision.peeled(other.name(), other)), parser.parse(other.name()));
+ }
+
+ public void testParseDiffRevisions() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit commit = repo.branch("master").commit().parent(parent).create();
+ RevCommit other = repo.branch("other").commit().create();
+
+ assertEquals(
+ new Result(
+ Revision.peeled("master", commit),
+ Revision.peeled("master^", parent),
+ 15),
+ parser.parse("master^..master"));
+ assertEquals(
+ new Result(
+ Revision.peeled("master", commit),
+ Revision.peeled("master^", parent),
+ 15),
+ parser.parse("master^..master/"));
+ assertEquals(
+ new Result(
+ Revision.peeled("master", commit),
+ Revision.peeled("master^", parent),
+ 15),
+ parser.parse("master^..master/path/to/a/file"));
+ assertEquals(
+ new Result(
+ Revision.peeled("master", commit),
+ Revision.peeled("master^", parent),
+ 15),
+ parser.parse("master^..master/path/to/a/..file"));
+ assertEquals(
+ new Result(
+ Revision.peeled("refs/heads/master", commit),
+ Revision.peeled("refs/heads/master^", parent),
+ 37),
+ parser.parse("refs/heads/master^..refs/heads/master"));
+ assertEquals(
+ new Result(
+ Revision.peeled("master", commit),
+ Revision.peeled("master~1", parent),
+ 16),
+ parser.parse("master~1..master"));
+ // TODO(dborowitz): 2a2362fbb in JGit causes master~2 to resolve to master
+ // rather than null. Uncomment when upstream regression is fixed.
+ //assertNull(parser.parse("master~2..master"));
+ assertEquals(
+ new Result(
+ Revision.peeled("master", commit),
+ Revision.peeled("other", other),
+ 13),
+ parser.parse("other..master"));
+ }
+
+ public void testParseFirstParentExpression() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit commit = repo.branch("master").commit().parent(parent).create();
+
+ assertEquals(
+ new Result(
+ Revision.peeled("master", commit),
+ Revision.peeled("master^", parent),
+ 8),
+ parser.parse("master^!"));
+ assertEquals(
+ new Result(
+ Revision.peeled("master^", parent),
+ Revision.NULL,
+ 9),
+ parser.parse("master^^!"));
+ assertEquals(
+ new Result(
+ Revision.peeled(parent.name(), parent),
+ Revision.NULL,
+ 42),
+ parser.parse(parent.name() + "^!"));
+
+ RevTag tag = repo.update("refs/tags/tag", repo.tag("tag", commit));
+ assertEquals(
+ new Result(
+ Revision.peeled("tag", commit),
+ Revision.peeled("tag^", parent),
+ 5),
+ parser.parse("tag^!"));
+ assertEquals(
+ new Result(
+ Revision.peeled("tag^", parent),
+ Revision.NULL,
+ 6),
+ parser.parse("tag^^!"));
+ }
+
+ public void testNonVisibleDiffShas() throws Exception {
+ RevCommit other = repo.commit().create();
+ RevCommit master = repo.branch("master").commit().create();
+ assertEquals(null, parser.parse("other..master"));
+ assertEquals(null, parser.parse("master..other"));
+
+ repo.branch("other").update(other);
+ assertEquals(
+ new Result(
+ Revision.peeled("master", master),
+ Revision.peeled("other", other),
+ 13),
+ parser.parse("other..master"));
+ assertEquals(
+ new Result(
+ Revision.peeled("other", other),
+ Revision.peeled("master", master),
+ 13),
+ parser.parse("master..other"));
+ }
+
+ public void testParseTag() throws Exception {
+ RevCommit master = repo.branch("master").commit().create();
+ RevTag masterTag = repo.update("refs/tags/master-tag", repo.tag("master-tag", master));
+ RevTag masterTagTag = repo.update("refs/tags/master-tag-tag",
+ repo.tag("master-tag-tag", master));
+
+ assertEquals(new Result(
+ new Revision("master-tag", masterTag, OBJ_TAG, master, OBJ_COMMIT)),
+ parser.parse("master-tag"));
+ assertEquals(new Result(
+ new Revision("master-tag-tag", masterTagTag, OBJ_TAG, master, OBJ_COMMIT)),
+ parser.parse("master-tag-tag"));
+
+ RevBlob blob = repo.update("refs/tags/blob", repo.blob("blob"));
+ RevTag blobTag = repo.update("refs/tags/blob-tag", repo.tag("blob-tag", blob));
+ assertEquals(new Result(Revision.peeled("blob", blob)), parser.parse("blob"));
+ assertEquals(new Result(new Revision("blob-tag", blobTag, OBJ_TAG, blob, OBJ_BLOB)),
+ parser.parse("blob-tag"));
+ }
+
+ public void testParseUnsupportedRevisionExpressions() throws Exception {
+ RevBlob blob = repo.blob("blob contents");
+ RevCommit master = repo.branch("master").commit().add("blob", blob).create();
+
+ assertEquals(master, repo.getRepository().resolve("master^{}"));
+ assertEquals(null, parser.parse("master^{}"));
+
+ assertEquals(master, repo.getRepository().resolve("master^{commit}"));
+ assertEquals(null, parser.parse("master^{commit}"));
+
+ assertEquals(blob, repo.getRepository().resolve("master:blob"));
+ assertEquals(null, parser.parse("master:blob"));
+
+ // TestRepository has no simple way of setting the reflog.
+ //assertEquals(null, repo.getRepository().resolve("master@{0}"));
+ assertEquals(null, parser.parse("master@{0}"));
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java
new file mode 100644
index 0000000..0b31e24
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesAccess.java
@@ -0,0 +1,64 @@
+// 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 org.eclipse.jgit.storage.dfs.DfsRepository;
+
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** Gitiles access for testing. */
+public class TestGitilesAccess implements GitilesAccess.Factory {
+ private final DfsRepository repo;
+
+ public TestGitilesAccess(DfsRepository repo) {
+ this.repo = checkNotNull(repo);
+ }
+
+ @Override
+ public GitilesAccess forRequest(final HttpServletRequest req) {
+ return new GitilesAccess() {
+ @Override
+ public Map<String, RepositoryDescription> listRepositories(Set<String> branches) {
+ // TODO(dborowitz): Implement this, using the DfsRepositoryDescriptions to
+ // get the repository names.
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Object getUserKey() {
+ return "a user";
+ }
+
+ @Override
+ public String getRepositoryName() {
+ return repo.getDescription().getRepositoryName();
+ }
+
+ @Override
+ public RepositoryDescription getRepositoryDescription() {
+ RepositoryDescription d = new RepositoryDescription();
+ d.name = getRepositoryName();
+ d.description = "a test data set";
+ d.cloneUrl = TestGitilesUrls.URLS.getBaseGitUrl(req) + "/" + d.name;
+ return d;
+ }
+ };
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesUrls.java b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesUrls.java
new file mode 100644
index 0000000..f8a0883
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/TestGitilesUrls.java
@@ -0,0 +1,40 @@
+// 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 javax.servlet.http.HttpServletRequest;
+
+/** {@link GitilesUrls} for testing. */
+public class TestGitilesUrls implements GitilesUrls {
+ public static final GitilesUrls URLS = new TestGitilesUrls();
+
+ @Override
+ public String getHostName(HttpServletRequest req) {
+ return "test-host";
+ }
+
+ @Override
+ public String getBaseGitUrl(HttpServletRequest req) {
+ return "git://test-host/foo";
+ }
+
+ @Override
+ public String getBaseGerritUrl(HttpServletRequest req) {
+ return "http://test-host-review/foo/";
+ }
+
+ private TestGitilesUrls() {
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/TreeSoyDataTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/TreeSoyDataTest.java
new file mode 100644
index 0000000..85423d9
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/TreeSoyDataTest.java
@@ -0,0 +1,61 @@
+// 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.gitiles.TreeSoyData.getTargetDisplayName;
+import static com.google.gitiles.TreeSoyData.resolveTargetUrl;
+
+import com.google.common.base.Strings;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Tests for {@link TreeSoyData}. */
+public class TreeSoyDataTest extends TestCase {
+ public void testGetTargetDisplayName() throws Exception {
+ assertEquals("foo", getTargetDisplayName("foo"));
+ assertEquals("foo/bar", getTargetDisplayName("foo/bar"));
+ assertEquals("a/a/a/a/a/a/a/a/a/a/bar",
+ getTargetDisplayName(Strings.repeat("a/", 10) + "bar"));
+ assertEquals("a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/bar",
+ getTargetDisplayName(Strings.repeat("a/", 34) + "bar"));
+ assertEquals(".../bar", getTargetDisplayName(Strings.repeat("a/", 35) + "bar"));
+ assertEquals(".../bar", getTargetDisplayName(Strings.repeat("a/", 100) + "bar"));
+ assertEquals("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ getTargetDisplayName(Strings.repeat("a", 80)));
+ }
+
+ public void testResolveTargetUrl() throws Exception {
+ ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+ GitilesView view = GitilesView.path()
+ .setServletPath("/x")
+ .setHostName("host")
+ .setRepositoryName("repo")
+ .setRevision(Revision.unpeeled("m", id))
+ .setTreePath("a/b/c")
+ .build();
+ assertNull(resolveTargetUrl(view, "/foo"));
+ assertEquals("/x/repo/+/m/a", resolveTargetUrl(view, "../../"));
+ assertEquals("/x/repo/+/m/a", resolveTargetUrl(view, ".././../"));
+ assertEquals("/x/repo/+/m/a", resolveTargetUrl(view, "..//../"));
+ assertEquals("/x/repo/+/m/a/d", resolveTargetUrl(view, "../../d"));
+ assertEquals("/x/repo/+/m/", resolveTargetUrl(view, "../../.."));
+ assertEquals("/x/repo/+/m/a/d/e", resolveTargetUrl(view, "../../d/e"));
+ assertEquals("/x/repo/+/m/a/b", resolveTargetUrl(view, "../d/../e/../"));
+ assertNull(resolveTargetUrl(view, "../../../../"));
+ assertNull(resolveTargetUrl(view, "../../a/../../.."));
+ }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
new file mode 100644
index 0000000..1427b9e
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -0,0 +1,346 @@
+// 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.gitiles.FakeHttpServletRequest.newRequest;
+import static com.google.gitiles.GitilesFilter.REPO_PATH_REGEX;
+import static com.google.gitiles.GitilesFilter.REPO_REGEX;
+import static com.google.gitiles.GitilesFilter.ROOT_REGEX;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Atomics;
+import com.google.gitiles.GitilesView.Type;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.http.server.glue.MetaFilter;
+import org.eclipse.jgit.http.server.glue.MetaServlet;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Pattern;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Tests for the view filter. */
+public class ViewFilterTest extends TestCase {
+ private TestRepository<DfsRepository> repo;
+
+ @Override
+ protected void setUp() throws Exception {
+ repo = new TestRepository<DfsRepository>(
+ new InMemoryRepository(new DfsRepositoryDescription("test")));
+ }
+
+ public void testNoCommand() throws Exception {
+ assertEquals(Type.HOST_INDEX, getView("/").getType());
+ assertEquals(Type.REPOSITORY_INDEX, getView("/repo").getType());
+ assertNull(getView("/repo/+"));
+ assertNull(getView("/repo/+/"));
+ }
+
+ public void testAutoCommand() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+ String hex = master.name();
+ String hexBranch = hex.substring(0, 10);
+ RevCommit hexCommit = repo.branch(hexBranch).commit().create();
+
+ assertEquals(Type.LOG, getView("/repo/+/master").getType());
+ assertEquals(Type.LOG, getView("/repo/+/" + hexBranch).getType());
+ assertEquals(Type.REVISION, getView("/repo/+/" + hex).getType());
+ assertEquals(Type.REVISION, getView("/repo/+/" + hex.substring(0, 7)).getType());
+ assertEquals(Type.PATH, getView("/repo/+/master/").getType());
+ assertEquals(Type.PATH, getView("/repo/+/" + hex + "/").getType());
+ assertEquals(Type.DIFF, getView("/repo/+/master^..master").getType());
+ assertEquals(Type.DIFF, getView("/repo/+/master^..master/").getType());
+ assertEquals(Type.DIFF, getView("/repo/+/" + parent.name() + ".." + hex + "/").getType());
+ }
+
+ public void testHostIndex() throws Exception {
+ GitilesView view = getView("/");
+ assertEquals(Type.HOST_INDEX, view.getType());
+ assertEquals("test-host", view.getHostName());
+ assertNull(view.getRepositoryName());
+ assertEquals(Revision.NULL, view.getRevision());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertNull(view.getTreePath());
+ }
+
+ public void testRepositoryIndex() throws Exception {
+ GitilesView view = getView("/repo");
+ assertEquals(Type.REPOSITORY_INDEX, view.getType());
+ assertEquals("repo", view.getRepositoryName());
+ assertEquals(Revision.NULL, view.getRevision());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertNull(view.getTreePath());
+ }
+
+ public void testBranches() throws Exception {
+ RevCommit master = repo.branch("refs/heads/master").commit().create();
+ RevCommit stable = repo.branch("refs/heads/stable").commit().create();
+ GitilesView view;
+
+ view = getView("/repo/+show/master");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertNull(view.getTreePath());
+
+ view = getView("/repo/+show/heads/master");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("heads/master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertNull(view.getTreePath());
+
+ view = getView("/repo/+show/refs/heads/master");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("refs/heads/master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertNull(view.getTreePath());
+
+ view = getView("/repo/+show/stable");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("stable", view.getRevision().getName());
+ assertEquals(stable, view.getRevision().getId());
+ assertNull(view.getTreePath());
+ }
+
+ public void testAmbiguousBranchAndTag() throws Exception {
+ RevCommit branch = repo.branch("refs/heads/name").commit().create();
+ RevCommit tag = repo.branch("refs/tags/name").commit().create();
+ GitilesView view;
+
+ view = getView("/repo/+show/name");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("name", view.getRevision().getName());
+ assertEquals(tag, view.getRevision().getId());
+ assertNull(view.getTreePath());
+
+ view = getView("/repo/+show/heads/name");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("heads/name", view.getRevision().getName());
+ assertEquals(branch, view.getRevision().getId());
+ assertNull(view.getTreePath());
+
+ view = getView("/repo/+show/refs/heads/name");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("refs/heads/name", view.getRevision().getName());
+ assertEquals(branch, view.getRevision().getId());
+ assertNull(view.getTreePath());
+
+ view = getView("/repo/+show/tags/name");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("tags/name", view.getRevision().getName());
+ assertEquals(tag, view.getRevision().getId());
+ assertNull(view.getTreePath());
+
+ view = getView("/repo/+show/refs/tags/name");
+ assertEquals(Type.REVISION, view.getType());
+ assertEquals("refs/tags/name", view.getRevision().getName());
+ assertEquals(tag, view.getRevision().getId());
+ assertNull(view.getTreePath());
+ }
+
+ public void testPath() throws Exception {
+ RevCommit master = repo.branch("refs/heads/master").commit().create();
+ GitilesView view;
+
+ view = getView("/repo/+show/master/");
+ assertEquals(Type.PATH, view.getType());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("", view.getTreePath());
+
+ view = getView("/repo/+show/master/foo");
+ assertEquals(Type.PATH, view.getType());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("foo", view.getTreePath());
+
+ view = getView("/repo/+show/master/foo/");
+ assertEquals(Type.PATH, view.getType());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("foo", view.getTreePath());
+
+ view = getView("/repo/+show/master/foo/bar");
+ assertEquals(Type.PATH, view.getType());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("foo/bar", view.getTreePath());
+ }
+
+ public void testMultipleSlashes() throws Exception {
+ RevCommit master = repo.branch("refs/heads/master").commit().create();
+ GitilesView view;
+
+ assertEquals(Type.HOST_INDEX, getView("//").getType());
+ assertEquals(Type.REPOSITORY_INDEX, getView("//repo").getType());
+ assertEquals(Type.REPOSITORY_INDEX, getView("//repo//").getType());
+ assertNull(getView("/repo/+//master"));
+ assertNull(getView("/repo/+/refs//heads//master"));
+ assertNull(getView("/repo/+//master//"));
+ assertNull(getView("/repo/+//master/foo//bar"));
+ }
+
+ public void testDiff() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+ GitilesView view;
+
+ view = getView("/repo/+diff/master^..master");
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("", view.getTreePath());
+
+ view = getView("/repo/+diff/master^..master/");
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("", view.getTreePath());
+
+ view = getView("/repo/+diff/master^..master/foo");
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("foo", view.getTreePath());
+
+ view = getView("/repo/+diff/refs/heads/master^..refs/heads/master");
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("refs/heads/master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("refs/heads/master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("", view.getTreePath());
+ }
+
+ public void testDiffAgainstEmptyCommit() throws Exception {
+ RevCommit master = repo.branch("refs/heads/master").commit().create();
+ GitilesView view = getView("/repo/+diff/master^!");
+ assertEquals(Type.DIFF, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertEquals("", view.getTreePath());
+ }
+
+ public void testLog() throws Exception {
+ RevCommit parent = repo.commit().create();
+ RevCommit master = repo.branch("refs/heads/master").commit().parent(parent).create();
+ GitilesView view;
+
+ assertNull(getView("/repo/+log"));
+ assertNull(getView("/repo/+log/"));
+
+ view = getView("/repo/+log/master");
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertEquals("", view.getTreePath());
+
+ view = getView("/repo/+log/master/");
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertEquals("", view.getTreePath());
+
+ view = getView("/repo/+log/master/foo");
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals(Revision.NULL, view.getOldRevision());
+ assertEquals("foo", view.getTreePath());
+
+ view = getView("/repo/+log/master^..master");
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("", view.getTreePath());
+
+ view = getView("/repo/+log/master^..master/");
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("", view.getTreePath());
+
+ view = getView("/repo/+log/master^..master/foo");
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("foo", view.getTreePath());
+
+ view = getView("/repo/+log/refs/heads/master^..refs/heads/master");
+ assertEquals(Type.LOG, view.getType());
+ assertEquals("refs/heads/master", view.getRevision().getName());
+ assertEquals(master, view.getRevision().getId());
+ assertEquals("refs/heads/master^", view.getOldRevision().getName());
+ assertEquals(parent, view.getOldRevision().getId());
+ assertEquals("", view.getTreePath());
+ }
+
+ private GitilesView getView(String pathAndQuery) throws ServletException, IOException {
+ final AtomicReference<GitilesView> view = Atomics.newReference();
+ HttpServlet testServlet = new HttpServlet() {
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) {
+ view.set(ViewFilter.getView(req));
+ }
+ };
+
+ ViewFilter vf = new ViewFilter(
+ new TestGitilesAccess(repo.getRepository()),
+ TestGitilesUrls.URLS,
+ new VisibilityCache(false));
+ MetaFilter mf = new MetaFilter();
+
+ for (Pattern p : ImmutableList.of(ROOT_REGEX, REPO_REGEX, REPO_PATH_REGEX)) {
+ mf.serveRegex(p)
+ .through(vf)
+ .with(testServlet);
+ }
+
+ FakeHttpServletRequest req = newRequest(repo.getRepository());
+ int q = pathAndQuery.indexOf('?');
+ if (q > 0) {
+ req.setPathInfo(pathAndQuery.substring(0, q));
+ req.setQueryString(pathAndQuery.substring(q + 1));
+ } else {
+ req.setPathInfo(pathAndQuery);
+ }
+ new MetaServlet(mf){}.service(req, new FakeHttpServletResponse());
+
+ return view.get();
+ }
+}
diff --git a/gitiles-war/pom.xml b/gitiles-war/pom.xml
new file mode 100644
index 0000000..9ad21f1
--- /dev/null
+++ b/gitiles-war/pom.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>com.google.gitiles</groupId>
+ <artifactId>gitiles-parent</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>gitiles-war</artifactId>
+ <packaging>war</packaging>
+ <name>Gitiles - WAR</name>
+
+ <description>
+ Gitiles packaged as a standard web application archive
+ </description>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.google.gitiles</groupId>
+ <artifactId>gitiles-servlet</artifactId>
+ <version>${project.version}</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.tomcat</groupId>
+ <artifactId>servlet-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jetty</groupId>
+ <artifactId>jetty-server</artifactId>
+ <version>${jettyVersion}</version>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-war-plugin</artifactId>
+ <configuration>
+ <webResources>
+ <resource>
+ <directory>../gitiles-servlet/src/main/resources/com/google/gitiles/static</directory>
+ <targetPath>+static</targetPath>
+ </resource>
+ </webResources>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/gitiles-war/src/main/webapp/WEB-INF/web.xml b/gitiles-war/src/main/webapp/WEB-INF/web.xml
new file mode 100644
index 0000000..b4ad57d
--- /dev/null
+++ b/gitiles-war/src/main/webapp/WEB-INF/web.xml
@@ -0,0 +1,35 @@
+<!DOCTYPE web-app PUBLIC
+ "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
+ "http://java.sun.com/dtd/web-app_2_3.dtd" >
+<!--
+ 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.
+-->
+<web-app>
+ <display-name>Gitiles</display-name>
+
+ <servlet>
+ <servlet-name>gitiles</servlet-name>
+ <servlet-class>com.google.gitiles.GitilesServlet</servlet-class>
+ <init-param>
+ <param-name>configPath</param-name>
+ <param-value>gitiles.config</param-value>
+ </init-param>
+ </servlet>
+
+ <servlet-mapping>
+ <servlet-name>gitiles</servlet-name>
+ <url-pattern>/*</url-pattern>
+ </servlet-mapping>
+</web-app>
diff --git a/gitiles-war/webdefault.xml b/gitiles-war/webdefault.xml
new file mode 100644
index 0000000..8eaddc4
--- /dev/null
+++ b/gitiles-war/webdefault.xml
@@ -0,0 +1,28 @@
+<!DOCTYPE web-app PUBLIC
+ "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
+ "http://java.sun.com/dtd/web-app_2_3.dtd" >
+<!--
+ 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.
+-->
+<web-app>
+ <servlet>
+ <servlet-name>default</servlet-name>
+ <servlet-class>org.eclipse.jetty.servlet.DefaultServlet</servlet-class>
+ </servlet>
+ <servlet-mapping>
+ <servlet-name>default</servlet-name>
+ <url-pattern>/+static/*</url-pattern>
+ </servlet-mapping>
+</web-app>
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..aedba11
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,204 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <groupId>com.google.gitiles</groupId>
+ <artifactId>gitiles-parent</artifactId>
+ <packaging>pom</packaging>
+ <version>1.0-SNAPSHOT</version>
+
+ <name>Gitiles - Parent</name>
+ <url>https://gerrit.googlesource.com/gitiles</url>
+
+ <description>
+ Gitiles - Simple Git Repository Browser
+ </description>
+
+ <properties>
+ <!-- Should track Gerrit's jgitVersion fairly closely. -->
+ <jgitVersion>2.0.0.201206130900-r.129-ge63f1c9</jgitVersion>
+ <jettyVersion>7.5.2.v20111006</jettyVersion>
+ </properties>
+
+ <modules>
+ <module>gitiles-servlet</module>
+ <module>gitiles-war</module>
+ </modules>
+
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ <version>13.0</version>
+ </dependency>
+
+ <dependency>
+ <groupId>com.google.template</groupId>
+ <artifactId>soy</artifactId>
+ <version>2011-22-12</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit</artifactId>
+ <version>${jgitVersion}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit.http.server</artifactId>
+ <version>${jgitVersion}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit.junit</artifactId>
+ <version>${jgitVersion}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.tomcat</groupId>
+ <artifactId>servlet-api</artifactId>
+ <version>6.0.29</version>
+ </dependency>
+
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <version>3.8.1</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>1.6.1</version>
+ </dependency>
+
+ <dependency>
+ <groupId>joda-time</groupId>
+ <artifactId>joda-time</artifactId>
+ <version>2.1</version>
+ </dependency>
+
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ <version>2.1</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-lang3</artifactId>
+ <version>3.1</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.eclipse.jetty</groupId>
+ <artifactId>jetty-server</artifactId>
+ <version>${jettyVersion}</version>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+
+ <build>
+ <pluginManagement>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <version>2.3.2</version>
+ <configuration>
+ <source>1.6</source>
+ <target>1.6</target>
+ <encoding>UTF-8</encoding>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <configuration>
+ <archive>
+ <manifestEntries>
+ <Implementation-Title>Gitiles - ${project.artifactId}</Implementation-Title>
+ <Implementation-Version>${project.version}</Implementation-Version>
+ <Implementation-Vendor>Gitiles</Implementation-Vendor>
+ <Implementation-Vendor-Id>com.google.gitiles</Implementation-Vendor-Id>
+ <Implementation-Vendor-URL>https://gerrit.googlesource.com/gitiles/</Implementation-Vendor-URL>
+ </manifestEntries>
+ </archive>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-war-plugin</artifactId>
+ <version>2.1.1</version>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <version>2.1.2</version>
+ </plugin>
+ </plugins>
+ </pluginManagement>
+
+ <plugins>
+ <plugin>
+ <groupId>org.mortbay.jetty</groupId>
+ <artifactId>jetty-maven-plugin</artifactId>
+ <version>${jettyVersion}</version>
+ <configuration>
+ <webApp>
+ <defaultsDescriptor>gitiles-war/webdefault.xml</defaultsDescriptor>
+ </webApp>
+ <war>gitiles-war/target/gitiles-war-${project.version}.war</war>
+ </configuration>
+ <!-- TODO(dborowitz): Separate execution with reloadable static
+ resources and templates. -->
+ </plugin>
+ </plugins>
+ </build>
+
+ <repositories>
+ <!-- For JGit and Soy snapshots. -->
+ <repository>
+ <id>gerrit-maven</id>
+ <url>https://gerrit-maven.commondatastorage.googleapis.com</url>
+ </repository>
+
+ <repository>
+ <id>jgit-repository</id>
+ <url>http://download.eclipse.org/jgit/maven</url>
+ </repository>
+
+ <repository>
+ <id>java.net-repository</id>
+ <url>http://download.java.net/maven/2/</url>
+ </repository>
+ </repositories>
+</project>