From eb870fc034460c2bab69039b21049d332a002ca1 Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Fri, 10 Aug 2012 17:46:11 -0400
Subject: [PATCH] Submodules support

---
 src/com/gitblit/utils/StringUtils.java             |   26 ++
 docs/01_features.mkd                               |    1 
 src/com/gitblit/utils/MarkdownUtils.java           |    4 
 src/com/gitblit/wicket/pages/TreePage.java         |   31 +++
 src/com/gitblit/wicket/GitBlitWebApp_pl.properties |    3 
 src/com/gitblit/wicket/pages/TreePage.html         |    7 
 tests/com/gitblit/tests/StringUtilsTest.java       |    7 
 distrib/gitblit.properties                         |   12 +
 src/com/gitblit/wicket/pages/MarkdownPage.java     |    6 
 src/com/gitblit/wicket/pages/RepositoryPage.java   |   87 +++++++++
 src/com/gitblit/models/SubmoduleModel.java         |   47 +++++
 src/com/gitblit/GitBlit.java                       |   15 +
 src/com/gitblit/wicket/GitBlitWebApp.properties    |    3 
 src/com/gitblit/wicket/GitBlitWebApp_ja.properties |    3 
 src/com/gitblit/wicket/pages/CommitPage.java       |   61 +++++-
 docs/04_releases.mkd                               |   28 ++
 src/com/gitblit/wicket/GitBlitWebApp_es.properties |    3 
 src/com/gitblit/models/PathModel.java              |   10 
 src/com/gitblit/wicket/pages/CommitDiffPage.java   |   50 ++++-
 src/com/gitblit/wicket/pages/SummaryPage.java      |    6 
 src/com/gitblit/utils/JGitUtils.java               |   80 +++++++-
 21 files changed, 432 insertions(+), 58 deletions(-)

diff --git a/distrib/gitblit.properties b/distrib/gitblit.properties
index a5a47b7..836ac05 100644
--- a/distrib/gitblit.properties
+++ b/distrib/gitblit.properties
@@ -37,6 +37,18 @@
 # SINCE 1.0.1
 git.searchExclusions =
 
+# List of regex url patterns for extracting a repository name when locating
+# submodules.
+#   e.g. git.submoduleUrlPatterns = .*?://github.com/(.*) will extract
+#   *gitblit/gitblit.git* from *git://github.com/gitblit/gitblit.git*
+# If no matches are found then the submodule repository name is assumed to be
+# whatever trails the last / character. (e.g. gitblit.git).
+#
+# SPACE-DELIMITED
+# CASE-SENSITIVE
+# SINCE 1.0.1
+git.submoduleUrlPatterns = .*?://github.com/(.*)
+
 # Allow push/pull over http/https with JGit servlet.
 # If you do NOT want to allow Git clients to clone/push to Gitblit set this
 # to false.  You might want to do this if you are only using ssh:// or git://.
diff --git a/docs/01_features.mkd b/docs/01_features.mkd
index a4fc802..fb964fa 100644
--- a/docs/01_features.mkd
+++ b/docs/01_features.mkd
@@ -23,6 +23,7 @@
 - LDAP authentication and optional LDAP-controlled Team memberships
 - Gravatar integration
 - Git-notes display support
+- Submodule support
 - gh-pages display support (Jekyll is not supported)
 - Branch metrics (uses Google Charts)
 - HEAD and Branch RSS feeds
diff --git a/docs/04_releases.mkd b/docs/04_releases.mkd
index e94d8c1..ab81cb3 100644
--- a/docs/04_releases.mkd
+++ b/docs/04_releases.mkd
@@ -11,12 +11,11 @@
 
 #### fixes
 
-- Support StartTLS in LdapUserService (Steffen Gebert, issue 122)
-- Do not index blobs in submodules (issue 119)
+- Do not index submodule links (issue 119)
 - Restore original user or team object on failure to update (issue 118)
 - Fixes to relative path determination in repository search algorithm for symlinks (issue 116)
 - Fix to GitServlet to allow pushing to symlinked repositories (issue 116)
-- Repository URL uses `X-Forwarded-Proto` and `X-Forwarded-Port`, if available, for reverse proxy configurations (issue 115)
+- Repository URL now uses `X-Forwarded-Proto` and `X-Forwarded-Port`, if available, for reverse proxy configurations (issue 115)
 - Output real RAW content, not simulated RAW content (issue 114)
 - Fixed Lucene charset encoding bug when reindexing a repository (issue 112)
 - Fixed search box linking to Lucene page for nested repository on Tomcat (issue 111)
@@ -25,9 +24,17 @@
 
 #### additions
 
-- Added a repository setting to control authorization as AUTHENTICATED or NAMED.  
-NAMED is the original behavior for authorizing against a list of permitted users or permitted teams.
-AUTHENTICATED allows restricted access for any authenticated user.
+- Preliminary bare repository submodule support  
+    **New:** *git.submoduleUrlPatterns=*
+    - *git.submoduleUrlPatterns* is a space-delimited list of regular expressions for extracting a repository name from a submodule url.  
+    For example, `git.submoduleUrlPatterns = .*?://github.com/(.*)` would extract *gitblit/gitblit.git* from *git://github.git/gitblit/gitblit.git*  
+    **Note:** You may not need this control to work with submodules, but it is there if you do.
+    - If there are no matches from *git.submoduleUrlPatterns* then the repository name is assumed to be whatever comes after the last `/` character *(e.g. gitblit.git)*
+    - Gitblit will try to locate this repository relative to the current repository *(e.g. myfolder/myrepo.git, myfolder/mysubmodule.git)* and then at the root level *(mysubmodule.git)* if that fails.
+    - Submodule references in a working copy will be properly identified as gitlinks, but Gitblit will not traverse into the working copy submodule repository.
+- Added a repository setting to control authorization as AUTHENTICATED or NAMED. (issue 117)  
+NAMED is the original behavior for authorizing against a list of permitted users or permitted teams.  
+AUTHENTICATED allows restricted access for any authenticated user.  This is a looser authorization control.
 - Added default authorization control setting (AUTHENTICATED or NAMED)  
     **New:** *git.defaultAuthorizationControl=NAMED*  
 - Added setting to control how deep Gitblit will recurse into *git.repositoriesFolder* looking for repositories (issue 103)  
@@ -36,10 +43,17 @@
     **New:** *git.searchExclusions=*  
 - Blob page now supports displaying images (issue 6)
 - Non-image binary files can now be downloaded using the RAW link
+- Support StartTLS in LdapUserService (Steffen Gebert, issue 122)
 
 #### changes
 
+- Line breaks inserted for readability in raw Markdown content display in the event of a parsing/transformation error.  An error message is now displayed prepended to the raw content.
+- Improve UTF-8 reading for Markdown files
 - Updated Polish translation
+
+<hr/>
+
+### Older Releases
 
 **1.0.0** *released 2012-07-14*
 
@@ -103,8 +117,6 @@
 - added Ivy 2.2.0
 
 <hr/>
-
-### Older Releases
 
 **0.9.3** *released 2012-04-11*
 
diff --git a/src/com/gitblit/GitBlit.java b/src/com/gitblit/GitBlit.java
index 26f30f9..f52f229 100644
--- a/src/com/gitblit/GitBlit.java
+++ b/src/com/gitblit/GitBlit.java
@@ -910,6 +910,21 @@
 		r.close();
 		return model;
 	}
+	
+	/**
+	 * Determines if this server has the requested repository.
+	 * 
+	 * @param name
+	 * @return true if the repository exists
+	 */
+	public boolean hasRepository(String repositoryName) {
+		Repository r = getRepository(repositoryName, false);
+		if (r == null) {
+			return false;
+		}
+		r.close();
+		return true;
+	}
 
 	/**
 	 * Returns the size in bytes of the repository. Gitblit caches the
diff --git a/src/com/gitblit/models/PathModel.java b/src/com/gitblit/models/PathModel.java
index 9bb7eb7..8692359 100644
--- a/src/com/gitblit/models/PathModel.java
+++ b/src/com/gitblit/models/PathModel.java
@@ -35,14 +35,16 @@
 	public final String path;
 	public final long size;
 	public final int mode;
+	public final String objectId;
 	public final String commitId;
 	public boolean isParentPath;
 
-	public PathModel(String name, String path, long size, int mode, String commitId) {
+	public PathModel(String name, String path, long size, int mode, String objectId, String commitId) {
 		this.name = name;
 		this.path = path;
 		this.size = size;
 		this.mode = mode;
+		this.objectId = objectId;
 		this.commitId = commitId;
 	}
 
@@ -102,9 +104,9 @@
 
 		public final ChangeType changeType;
 
-		public PathChangeModel(String name, String path, long size, int mode, String commitId,
-				ChangeType type) {
-			super(name, path, size, mode, commitId);
+		public PathChangeModel(String name, String path, long size, int mode, String objectId,
+				String commitId, ChangeType type) {
+			super(name, path, size, mode, objectId, commitId);
 			this.changeType = type;
 		}
 
diff --git a/src/com/gitblit/models/SubmoduleModel.java b/src/com/gitblit/models/SubmoduleModel.java
new file mode 100644
index 0000000..47f84b9
--- /dev/null
+++ b/src/com/gitblit/models/SubmoduleModel.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2012 gitblit.com.
+ *
+ * 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.gitblit.models;
+
+import java.io.Serializable;
+
+/**
+ * SubmoduleModel is a serializable model class that represents a git submodule
+ * definition.
+ * 
+ * @author James Moger
+ * 
+ */
+public class SubmoduleModel implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	public final String name;
+	public final String path;
+	public final String url;
+
+	public boolean hasSubmodule;
+	public String gitblitPath;
+
+	public SubmoduleModel(String name, String path, String url) {
+		this.name = name;
+		this.path = path;
+		this.url = url;
+	}
+	
+	public String toString() {
+		return path + "=" + url;
+	}
+}
\ No newline at end of file
diff --git a/src/com/gitblit/utils/JGitUtils.java b/src/com/gitblit/utils/JGitUtils.java
index 90e6a76..5eb83ed 100644
--- a/src/com/gitblit/utils/JGitUtils.java
+++ b/src/com/gitblit/utils/JGitUtils.java
@@ -45,7 +45,9 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.StopWalkException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
 import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
@@ -87,6 +89,7 @@
 import com.gitblit.models.PathModel;
 import com.gitblit.models.PathModel.PathChangeModel;
 import com.gitblit.models.RefModel;
+import com.gitblit.models.SubmoduleModel;
 
 /**
  * Collection of static methods for retrieving information from a repository.
@@ -732,7 +735,8 @@
 				tw.addTree(commit.getTree());
 				while (tw.next()) {
 					list.add(new PathChangeModel(tw.getPathString(), tw.getPathString(), 0, tw
-							.getRawMode(0), commit.getId().getName(), ChangeType.ADD));
+							.getRawMode(0), tw.getObjectId(0).getName(), commit.getId().getName(),
+							ChangeType.ADD));
 				}
 				tw.release();
 			} else {
@@ -745,15 +749,15 @@
 				for (DiffEntry diff : diffs) {
 					if (diff.getChangeType().equals(ChangeType.DELETE)) {
 						list.add(new PathChangeModel(diff.getOldPath(), diff.getOldPath(), 0, diff
-								.getNewMode().getBits(), commit.getId().getName(), diff
+								.getNewMode().getBits(), null, commit.getId().getName(), diff
 								.getChangeType()));
 					} else if (diff.getChangeType().equals(ChangeType.RENAME)) {
 						list.add(new PathChangeModel(diff.getOldPath(), diff.getNewPath(), 0, diff
-								.getNewMode().getBits(), commit.getId().getName(), diff
+								.getNewMode().getBits(), null, commit.getId().getName(), diff
 								.getChangeType()));
 					} else {
 						list.add(new PathChangeModel(diff.getNewPath(), diff.getNewPath(), 0, diff
-								.getNewMode().getBits(), commit.getId().getName(), diff
+								.getNewMode().getBits(), null, commit.getId().getName(), diff
 								.getChangeType()));
 					}
 				}
@@ -846,15 +850,16 @@
 		} else {
 			name = tw.getPathString().substring(basePath.length() + 1);
 		}
+		ObjectId objectId = tw.getObjectId(0);
 		try {
-			if (!tw.isSubtree()) {
-				size = tw.getObjectReader().getObjectSize(tw.getObjectId(0), Constants.OBJ_BLOB);
+			if (!tw.isSubtree() && (tw.getFileMode(0) != FileMode.GITLINK)) {
+				size = tw.getObjectReader().getObjectSize(objectId, Constants.OBJ_BLOB);
 			}
 		} catch (Throwable t) {
 			error(t, null, "failed to retrieve blob size for " + tw.getPathString());
 		}
 		return new PathModel(name, tw.getPathString(), size, tw.getFileMode(0).getBits(),
-				commit.getName());
+				objectId.getName(), commit.getName());
 	}
 
 	/**
@@ -871,13 +876,10 @@
 		} else if (FileMode.EXECUTABLE_FILE.equals(mode)) {
 			return "-rwxr-xr-x";
 		} else if (FileMode.SYMLINK.equals(mode)) {
-			// FIXME symlink permissions
 			return "symlink";
 		} else if (FileMode.GITLINK.equals(mode)) {
-			// FIXME gitlink permissions
-			return "gitlink";
+			return "submodule";
 		}
-		// FIXME missing permissions
 		return "missing";
 	}
 
@@ -1533,6 +1535,62 @@
 		}
 		return branch;
 	}
+		
+	/**
+	 * Returns the list of submodules for this repository.
+	 * 
+	 * @param repository
+	 * @param commit
+	 * @return list of submodules
+	 */
+	public static List<SubmoduleModel> getSubmodules(Repository repository, String commitId) {
+		RevCommit commit = getCommit(repository, commitId);
+		return getSubmodules(repository, commit.getTree());
+	}
+	
+	/**
+	 * Returns the list of submodules for this repository.
+	 * 
+	 * @param repository
+	 * @param commit
+	 * @return list of submodules
+	 */
+	public static List<SubmoduleModel> getSubmodules(Repository repository, RevTree tree) {
+		List<SubmoduleModel> list = new ArrayList<SubmoduleModel>();
+		byte [] blob = getByteContent(repository, tree, ".gitmodules");
+		if (blob == null) {
+			return list;
+		}
+		try {
+			BlobBasedConfig config = new BlobBasedConfig(repository.getConfig(), blob);
+			for (String module : config.getSubsections("submodule")) {
+				String path = config.getString("submodule", module, "path");
+				String url = config.getString("submodule", module, "url");
+				list.add(new SubmoduleModel(module, path, url));
+			}
+		} catch (ConfigInvalidException e) {
+			LOGGER.error("Failed to load .gitmodules file for " + repository.getDirectory(), e);
+		}
+		return list;
+	}
+	
+	/**
+	 * Returns the submodule definition for the specified path at the specified
+	 * commit.  If no module is defined for the path, null is returned.
+	 * 
+	 * @param repository
+	 * @param commit
+	 * @param path
+	 * @return a submodule definition or null if there is no submodule
+	 */
+	public static SubmoduleModel getSubmoduleModel(Repository repository, String commitId, String path) {
+		for (SubmoduleModel model : getSubmodules(repository, commitId)) {
+			if (model.path.equals(path)) {
+				return model;
+			}
+		}
+		return null;
+	}
 
 	/**
 	 * Returns the list of notes entered about the commit from the refs/notes
diff --git a/src/com/gitblit/utils/MarkdownUtils.java b/src/com/gitblit/utils/MarkdownUtils.java
index a3a1859..828225d 100644
--- a/src/com/gitblit/utils/MarkdownUtils.java
+++ b/src/com/gitblit/utils/MarkdownUtils.java
@@ -20,6 +20,7 @@
 import java.io.StringReader;
 import java.io.StringWriter;
 
+import org.slf4j.LoggerFactory;
 import org.tautua.markdownpapers.Markdown;
 import org.tautua.markdownpapers.parser.ParseException;
 
@@ -44,6 +45,8 @@
 			String html = transformMarkdown(reader);
 			reader.close();
 			return html;
+		} catch (IllegalArgumentException e) {
+			throw new java.text.ParseException(e.getMessage(), 0);
 		} catch (NullPointerException p) {
 			throw new java.text.ParseException("Markdown string is null!", 0);
 		}
@@ -65,6 +68,7 @@
 			md.transform(markdownReader, writer);
 			return writer.toString().trim();
 		} catch (ParseException p) {
+			LoggerFactory.getLogger(MarkdownUtils.class).error("MarkdownPapers failed to parse Markdown!", p);
 			throw new java.text.ParseException(p.getMessage(), 0);
 		} finally {
 			try {
diff --git a/src/com/gitblit/utils/StringUtils.java b/src/com/gitblit/utils/StringUtils.java
index 412a920..3972f20 100644
--- a/src/com/gitblit/utils/StringUtils.java
+++ b/src/com/gitblit/utils/StringUtils.java
@@ -33,6 +33,8 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 
 /**
@@ -599,4 +601,28 @@
         }
 		return value;
 	}
+	
+	/**
+	 * Attempt to extract a repository name from a given url using regular
+	 * expressions.  If no match is made, then return whatever trails after
+	 * the final / character.
+	 * 
+	 * @param regexUrls
+	 * @return a repository path
+	 */
+	public static String extractRepositoryPath(String url, String... urlpatterns) {
+		for (String urlPattern : urlpatterns) {
+			Pattern p = Pattern.compile(urlPattern);
+			Matcher m = p.matcher(url);
+			while (m.find()) {
+				String repositoryPath = m.group(1);
+				return repositoryPath;
+			}
+		}
+		// last resort
+		if (url.lastIndexOf('/') > -1) {
+			return url.substring(url.lastIndexOf('/') + 1);
+		}
+		return url;
+	}
 }
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/GitBlitWebApp.properties b/src/com/gitblit/wicket/GitBlitWebApp.properties
index bcd6337..f9480a8 100644
--- a/src/com/gitblit/wicket/GitBlitWebApp.properties
+++ b/src/com/gitblit/wicket/GitBlitWebApp.properties
@@ -312,4 +312,5 @@
 gb.duration.years = {0} years
 gb.authorizationControl = authorization control
 gb.allowAuthenticatedDescription = grant restricted access to all authenticated users
-gb.allowNamedDescription = grant restricted access to named users or teams
\ No newline at end of file
+gb.allowNamedDescription = grant restricted access to named users or teams
+gb.markdownFailure = Failed to parse Markdown content!
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/GitBlitWebApp_es.properties b/src/com/gitblit/wicket/GitBlitWebApp_es.properties
index fec51b8..2140f52 100644
--- a/src/com/gitblit/wicket/GitBlitWebApp_es.properties
+++ b/src/com/gitblit/wicket/GitBlitWebApp_es.properties
@@ -312,4 +312,5 @@
 gb.duration.years = {0} a\u00F1os
 gb.authorizationControl = authorization control
 gb.allowAuthenticatedDescription = grant restricted access to all authenticated users
-gb.allowNamedDescription = grant restricted access to named users or teams
\ No newline at end of file
+gb.allowNamedDescription = grant restricted access to named users or teams
+gb.markdownFailure = Failed to parse Markdown content!
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/GitBlitWebApp_ja.properties b/src/com/gitblit/wicket/GitBlitWebApp_ja.properties
index f0fe3e8..04e2bf3 100755
--- a/src/com/gitblit/wicket/GitBlitWebApp_ja.properties
+++ b/src/com/gitblit/wicket/GitBlitWebApp_ja.properties
@@ -312,4 +312,5 @@
 gb.duration.years = {0} years
 gb.authorizationControl = authorization control
 gb.allowAuthenticatedDescription = grant restricted access to all authenticated users
-gb.allowNamedDescription = grant restricted access to named users or teams
\ No newline at end of file
+gb.allowNamedDescription = grant restricted access to named users or teams
+gb.markdownFailure = Failed to parse Markdown content!
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/GitBlitWebApp_pl.properties b/src/com/gitblit/wicket/GitBlitWebApp_pl.properties
index 9486595..7c35dab 100644
--- a/src/com/gitblit/wicket/GitBlitWebApp_pl.properties
+++ b/src/com/gitblit/wicket/GitBlitWebApp_pl.properties
@@ -312,4 +312,5 @@
 gb.duration.years = {0} lat
 gb.authorizationControl = authorization control
 gb.allowAuthenticatedDescription = grant restricted access to all authenticated users
-gb.allowNamedDescription = grant restricted access to named users or teams
\ No newline at end of file
+gb.allowNamedDescription = grant restricted access to named users or teams
+gb.markdownFailure = Failed to parse Markdown content!
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/pages/CommitDiffPage.java b/src/com/gitblit/wicket/pages/CommitDiffPage.java
index cee065b..dbf981b 100644
--- a/src/com/gitblit/wicket/pages/CommitDiffPage.java
+++ b/src/com/gitblit/wicket/pages/CommitDiffPage.java
@@ -31,6 +31,7 @@
 import com.gitblit.GitBlit;
 import com.gitblit.Keys;
 import com.gitblit.models.PathModel.PathChangeModel;
+import com.gitblit.models.SubmoduleModel;
 import com.gitblit.utils.DiffUtils;
 import com.gitblit.utils.DiffUtils.DiffOutputType;
 import com.gitblit.utils.JGitUtils;
@@ -86,26 +87,55 @@
 				setChangeTypeTooltip(changeType, entry.changeType);
 				item.add(changeType);
 
+				boolean hasSubmodule = false;
+				String submodulePath = null;
 				if (entry.isTree()) {
+					// tree
 					item.add(new LinkPanel("pathName", null, entry.path, TreePage.class,
 							WicketUtils
 									.newPathParameter(repositoryName, entry.commitId, entry.path)));
+				} else if (entry.isSubmodule()) {
+					// submodule
+					String submoduleId = entry.objectId;						
+					SubmoduleModel submodule = getSubmodule(entry.path);
+					submodulePath = submodule.gitblitPath;
+					hasSubmodule = submodule.hasSubmodule;
+					
+					item.add(new LinkPanel("pathName", "list", entry.path + " @ " +
+							getShortObjectId(submoduleId), TreePage.class,
+							WicketUtils
+									.newPathParameter(submodulePath, submoduleId, "")).setEnabled(hasSubmodule));
 				} else {
+					// blob
 					item.add(new LinkPanel("pathName", "list", entry.path, BlobPage.class,
 							WicketUtils
 									.newPathParameter(repositoryName, entry.commitId, entry.path)));
 				}
 
-				item.add(new BookmarkablePageLink<Void>("patch", PatchPage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path)));
-				item.add(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path)));
-				item.add(new BookmarkablePageLink<Void>("blame", BlamePage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path)));
-				item.add(new BookmarkablePageLink<Void>("history", HistoryPage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path))
-						.setEnabled(!entry.changeType.equals(ChangeType.ADD)));
-
+				// quick links
+				if (entry.isSubmodule()) {
+					// submodule					
+					item.add(new BookmarkablePageLink<Void>("patch", PatchPage.class, WicketUtils
+							.newPathParameter(submodulePath, entry.objectId, entry.path)).setEnabled(false));
+					item.add(new BookmarkablePageLink<Void>("view", CommitPage.class, WicketUtils
+							.newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule));
+					item.add(new BookmarkablePageLink<Void>("blame", BlamePage.class, WicketUtils
+							.newPathParameter(submodulePath, entry.objectId, entry.path)).setEnabled(false));
+					item.add(new BookmarkablePageLink<Void>("history", HistoryPage.class, WicketUtils
+							.newPathParameter(submodulePath, entry.objectId, entry.path))
+							.setEnabled(hasSubmodule));
+				} else {
+					// tree or blob
+					item.add(new BookmarkablePageLink<Void>("patch", PatchPage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path)));
+					item.add(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path)));
+					item.add(new BookmarkablePageLink<Void>("blame", BlamePage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path)));
+					item.add(new BookmarkablePageLink<Void>("history", HistoryPage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path))
+							.setEnabled(!entry.changeType.equals(ChangeType.ADD)));
+				}
 				WicketUtils.setAlternatingBackground(item, counter);
 				counter++;
 			}
diff --git a/src/com/gitblit/wicket/pages/CommitPage.java b/src/com/gitblit/wicket/pages/CommitPage.java
index f3b3265..7bc6b41 100644
--- a/src/com/gitblit/wicket/pages/CommitPage.java
+++ b/src/com/gitblit/wicket/pages/CommitPage.java
@@ -36,6 +36,7 @@
 import com.gitblit.GitBlit;
 import com.gitblit.Keys;
 import com.gitblit.models.GitNote;
+import com.gitblit.models.SubmoduleModel;
 import com.gitblit.models.PathModel.PathChangeModel;
 import com.gitblit.utils.JGitUtils;
 import com.gitblit.wicket.WicketUtils;
@@ -52,7 +53,7 @@
 
 		Repository r = getRepository();
 		RevCommit c = getCommit();
-
+		
 		List<String> parents = new ArrayList<String>();
 		if (c.getParentCount() > 0) {
 			for (RevCommit parent : c.getParents()) {
@@ -150,28 +151,60 @@
 				WicketUtils.setChangeTypeCssClass(changeType, entry.changeType);
 				setChangeTypeTooltip(changeType, entry.changeType);
 				item.add(changeType);
+				
+				boolean hasSubmodule = false;
+				String submodulePath = null;
 				if (entry.isTree()) {
+					// tree
 					item.add(new LinkPanel("pathName", null, entry.path, TreePage.class,
 							WicketUtils
 									.newPathParameter(repositoryName, entry.commitId, entry.path)));
+				} else if (entry.isSubmodule()) {
+					// submodule
+					String submoduleId = entry.objectId;
+					SubmoduleModel submodule = getSubmodule(entry.path);
+					submodulePath = submodule.gitblitPath;
+					hasSubmodule = submodule.hasSubmodule;
+					
+					item.add(new LinkPanel("pathName", "list", entry.path + " @ " +
+							getShortObjectId(submoduleId), TreePage.class,
+							WicketUtils.newPathParameter(submodulePath, submoduleId, "")).setEnabled(hasSubmodule));
 				} else {
+					// blob
 					item.add(new LinkPanel("pathName", "list", entry.path, BlobPage.class,
 							WicketUtils
 									.newPathParameter(repositoryName, entry.commitId, entry.path)));
 				}
-
-				item.add(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path))
-						.setEnabled(!entry.changeType.equals(ChangeType.ADD)
-								&& !entry.changeType.equals(ChangeType.DELETE)));
-				item.add(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path)));
-				item.add(new BookmarkablePageLink<Void>("blame", BlamePage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path))
-						.setEnabled(!entry.changeType.equals(ChangeType.ADD)));
-				item.add(new BookmarkablePageLink<Void>("history", HistoryPage.class, WicketUtils
-						.newPathParameter(repositoryName, entry.commitId, entry.path))
-						.setEnabled(!entry.changeType.equals(ChangeType.ADD)));
+				
+				// quick links
+				if (entry.isSubmodule()) {
+					// submodule					
+					item.add(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
+							.newPathParameter(submodulePath, entry.objectId, entry.path))
+							.setEnabled(false));
+					item.add(new BookmarkablePageLink<Void>("view", CommitPage.class, WicketUtils
+							.newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule));
+					item.add(new BookmarkablePageLink<Void>("blame", BlamePage.class, WicketUtils
+							.newPathParameter(submodulePath, entry.objectId, entry.path))
+							.setEnabled(false));
+					item.add(new BookmarkablePageLink<Void>("history", HistoryPage.class, WicketUtils
+							.newPathParameter(submodulePath, entry.objectId, entry.path))
+							.setEnabled(hasSubmodule));
+				} else {
+					// tree or blob
+					item.add(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path))
+							.setEnabled(!entry.changeType.equals(ChangeType.ADD)
+									&& !entry.changeType.equals(ChangeType.DELETE)));
+					item.add(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path)));
+					item.add(new BookmarkablePageLink<Void>("blame", BlamePage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path))
+							.setEnabled(!entry.changeType.equals(ChangeType.ADD)));
+					item.add(new BookmarkablePageLink<Void>("history", HistoryPage.class, WicketUtils
+							.newPathParameter(repositoryName, entry.commitId, entry.path))
+							.setEnabled(!entry.changeType.equals(ChangeType.ADD)));
+				}
 
 				WicketUtils.setAlternatingBackground(item, counter);
 				counter++;
diff --git a/src/com/gitblit/wicket/pages/MarkdownPage.java b/src/com/gitblit/wicket/pages/MarkdownPage.java
index 5764235..e032cbf 100644
--- a/src/com/gitblit/wicket/pages/MarkdownPage.java
+++ b/src/com/gitblit/wicket/pages/MarkdownPage.java
@@ -15,6 +15,7 @@
  */
 package com.gitblit.wicket.pages;
 
+import java.text.MessageFormat;
 import java.text.ParseException;
 
 import org.apache.wicket.PageParameters;
@@ -27,6 +28,7 @@
 import com.gitblit.GitBlit;
 import com.gitblit.utils.JGitUtils;
 import com.gitblit.utils.MarkdownUtils;
+import com.gitblit.utils.StringUtils;
 import com.gitblit.wicket.WicketUtils;
 
 public class MarkdownPage extends RepositoryPage {
@@ -56,8 +58,8 @@
 		try {
 			htmlText = MarkdownUtils.transformMarkdown(markdownText);
 		} catch (ParseException p) {
-			error(p.getMessage());
-			htmlText = markdownText;
+			markdownText = MessageFormat.format("<div class=\"alert alert-error\"><strong>{0}:</strong> {1}</div>{2}", getString("gb.error"), getString("gb.markdownFailure"), markdownText);
+			htmlText = StringUtils.breakLinesForHtml(markdownText);
 		}
 
 		// Add the html to the page
diff --git a/src/com/gitblit/wicket/pages/RepositoryPage.java b/src/com/gitblit/wicket/pages/RepositoryPage.java
index 4b5e28d..6d33a14 100644
--- a/src/com/gitblit/wicket/pages/RepositoryPage.java
+++ b/src/com/gitblit/wicket/pages/RepositoryPage.java
@@ -19,9 +19,12 @@
 import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import org.apache.wicket.Component;
 import org.apache.wicket.PageParameters;
@@ -45,6 +48,7 @@
 import com.gitblit.PagesServlet;
 import com.gitblit.SyndicationServlet;
 import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.SubmoduleModel;
 import com.gitblit.utils.ArrayUtils;
 import com.gitblit.utils.JGitUtils;
 import com.gitblit.utils.StringUtils;
@@ -62,18 +66,20 @@
 
 	protected final String repositoryName;
 	protected final String objectId;
-
+	
 	private transient Repository r;
 
 	private RepositoryModel m;
 
+	private Map<String, SubmoduleModel> submodules;
+	
 	private final Map<String, PageRegistration> registeredPages;
 
 	public RepositoryPage(PageParameters params) {
 		super(params);
 		repositoryName = WicketUtils.getRepositoryName(params);
 		objectId = WicketUtils.getObject(params);
-
+		
 		if (StringUtils.isEmpty(repositoryName)) {
 			error(MessageFormat.format(getString("gb.repositoryNotSpecifiedFor"), getPageName()), true);
 		}
@@ -206,8 +212,85 @@
 			error(MessageFormat.format(getString("gb.failedToFindCommit"),
 					objectId, repositoryName, getPageName()), true);
 		}
+		getSubmodules(commit);
 		return commit;
 	}
+	
+	private Map<String, SubmoduleModel> getSubmodules(RevCommit commit) {	
+		if (submodules == null) {
+			submodules = new HashMap<String, SubmoduleModel>();
+			for (SubmoduleModel model : JGitUtils.getSubmodules(r, commit.getTree())) {
+				submodules.put(model.path, model);
+			}
+		}
+		return submodules;
+	}
+	
+	protected Map<String, SubmoduleModel> getSubmodules() {
+		return submodules;
+	}
+	
+	protected SubmoduleModel getSubmodule(String path) {
+		SubmoduleModel model = submodules.get(path);
+		if (model == null) {
+			// undefined submodule?!
+			model = new SubmoduleModel(path.substring(path.lastIndexOf('/') + 1), path, path);
+			model.hasSubmodule = false;
+			model.gitblitPath = model.name;
+			return model;
+		} else {
+			// extract the repository name from the clone url
+			List<String> patterns = GitBlit.getStrings(Keys.git.submoduleUrlPatterns);
+			String submoduleName = StringUtils.extractRepositoryPath(model.url, patterns.toArray(new String[0]));
+			
+			// determine the current path for constructing paths relative
+			// to the current repository
+			String currentPath = "";
+			if (repositoryName.indexOf('/') > -1) {
+				currentPath = repositoryName.substring(0, repositoryName.lastIndexOf('/') + 1);
+			}
+
+			// try to locate the submodule repository
+			// prefer bare to non-bare names
+			List<String> candidates = new ArrayList<String>();
+
+			// relative
+			candidates.add(currentPath + StringUtils.stripDotGit(submoduleName));
+			candidates.add(candidates.get(candidates.size() - 1) + ".git");
+
+			// relative, no subfolder
+			if (submoduleName.lastIndexOf('/') > -1) {
+				String name = submoduleName.substring(submoduleName.lastIndexOf('/') + 1);
+				candidates.add(currentPath + StringUtils.stripDotGit(name));
+				candidates.add(currentPath + candidates.get(candidates.size() - 1) + ".git");
+			}
+
+			// absolute
+			candidates.add(StringUtils.stripDotGit(submoduleName));
+			candidates.add(candidates.get(candidates.size() - 1) + ".git");
+
+			// absolute, no subfolder
+			if (submoduleName.lastIndexOf('/') > -1) {
+				String name = submoduleName.substring(submoduleName.lastIndexOf('/') + 1);
+				candidates.add(StringUtils.stripDotGit(name));
+				candidates.add(candidates.get(candidates.size() - 1) + ".git");
+			}
+
+			// create a unique, ordered set of candidate paths
+			Set<String> paths = new LinkedHashSet<String>(candidates);
+			for (String candidate : paths) {
+				if (GitBlit.self().hasRepository(candidate)) {
+					model.hasSubmodule = true;
+					model.gitblitPath = candidate;
+					return model;
+				}
+			}
+			
+			// we do not have a copy of the submodule, but we need a path
+			model.gitblitPath = candidates.get(0);
+			return model;
+		}		
+	}
 
 	protected String getShortObjectId(String objectId) {
 		return objectId.substring(0, 8);
diff --git a/src/com/gitblit/wicket/pages/SummaryPage.java b/src/com/gitblit/wicket/pages/SummaryPage.java
index cb507d2..2a624c2 100644
--- a/src/com/gitblit/wicket/pages/SummaryPage.java
+++ b/src/com/gitblit/wicket/pages/SummaryPage.java
@@ -136,6 +136,7 @@
 
 		if (getRepositoryModel().showReadme) {
 			String htmlText = null;
+			String markdownText = null;
 			String readme = null;
 			try {
 				RevCommit head = JGitUtils.getCommit(r, null);
@@ -158,11 +159,12 @@
 				}
 				if (!StringUtils.isEmpty(readme)) {
 					String [] encodings = GitBlit.getEncodings();
-					String markdownText = JGitUtils.getStringContent(r, head.getTree(), readme, encodings);
+					markdownText = JGitUtils.getStringContent(r, head.getTree(), readme, encodings);
 					htmlText = MarkdownUtils.transformMarkdown(markdownText);
 				}
 			} catch (ParseException p) {
-				error(p.getMessage());
+				markdownText = MessageFormat.format("<div class=\"alert alert-error\"><strong>{0}:</strong> {1}</div>{2}", getString("gb.error"), getString("gb.markdownFailure"), markdownText);
+				htmlText = StringUtils.breakLinesForHtml(markdownText);
 			}
 			Fragment fragment = new Fragment("readme", "markdownPanel");
 			fragment.add(new Label("readmeFile", readme));
diff --git a/src/com/gitblit/wicket/pages/TreePage.html b/src/com/gitblit/wicket/pages/TreePage.html
index 760294f..0047ff0 100644
--- a/src/com/gitblit/wicket/pages/TreePage.html
+++ b/src/com/gitblit/wicket/pages/TreePage.html
@@ -29,6 +29,13 @@
 		</tr>
 	</table>
 
+	<!--  submodule links -->
+	<wicket:fragment wicket:id="submoduleLinks">
+		<span class="link">
+			<a wicket:id="view"><wicket:message key="gb.view"></wicket:message></a> | <span class="hidden-phone"><a wicket:id="tree"><wicket:message key="gb.tree"></wicket:message></a> | </span><a wicket:id="history"><wicket:message key="gb.history"></wicket:message></a> | <a wicket:id="zip"><wicket:message key="gb.zip"></wicket:message></a>
+		</span>
+	</wicket:fragment>
+
 	<!--  tree links -->
 	<wicket:fragment wicket:id="treeLinks">
 		<span class="link">
diff --git a/src/com/gitblit/wicket/pages/TreePage.java b/src/com/gitblit/wicket/pages/TreePage.java
index 7fc91ee..973634b 100644
--- a/src/com/gitblit/wicket/pages/TreePage.java
+++ b/src/com/gitblit/wicket/pages/TreePage.java
@@ -34,6 +34,7 @@
 import com.gitblit.GitBlit;
 import com.gitblit.Keys;
 import com.gitblit.models.PathModel;
+import com.gitblit.models.SubmoduleModel;
 import com.gitblit.utils.ByteFormat;
 import com.gitblit.utils.JGitUtils;
 import com.gitblit.wicket.WicketUtils;
@@ -71,7 +72,7 @@
 			if (path.lastIndexOf('/') > -1) {
 				parentPath = path.substring(0, path.lastIndexOf('/'));
 			}
-			PathModel model = new PathModel("..", parentPath, 0, FileMode.TREE.getBits(), objectId);
+			PathModel model = new PathModel("..", parentPath, 0, FileMode.TREE.getBits(), null, objectId);
 			model.isParentPath = true;
 			paths.add(0, model);
 		}
@@ -118,6 +119,34 @@
 								repositoryName, objectId, entry.path)).setVisible(GitBlit
 								.getBoolean(Keys.web.allowZipDownloads, true)));
 						item.add(links);
+					} else if (entry.isSubmodule()) {
+						// submodule
+						String submoduleId = entry.objectId;						
+						String submodulePath;
+						boolean hasSubmodule = false;
+						SubmoduleModel submodule = getSubmodule(entry.path);
+						submodulePath = submodule.gitblitPath;
+						hasSubmodule = submodule.hasSubmodule;
+						
+						item.add(WicketUtils.newImage("pathIcon", "git-orange-16x16.png"));
+						item.add(new Label("pathSize", ""));
+						item.add(new LinkPanel("pathName", "list", entry.name + " @ " + 
+								getShortObjectId(submoduleId), TreePage.class,
+								WicketUtils.newPathParameter(submodulePath, submoduleId, "")).setEnabled(hasSubmodule));
+						
+						Fragment links = new Fragment("pathLinks", "submoduleLinks", this);
+						links.add(new BookmarkablePageLink<Void>("view", SummaryPage.class,
+								WicketUtils.newRepositoryParameter(submodulePath)).setEnabled(hasSubmodule));
+						links.add(new BookmarkablePageLink<Void>("tree", TreePage.class,
+								WicketUtils.newPathParameter(submodulePath, submoduleId,
+										"")).setEnabled(hasSubmodule));
+						links.add(new BookmarkablePageLink<Void>("history", HistoryPage.class,
+								WicketUtils.newPathParameter(submodulePath, submoduleId,
+										"")).setEnabled(hasSubmodule));
+						links.add(new ExternalLink("zip", DownloadZipServlet.asLink(baseUrl,
+								submodulePath, submoduleId, "")).setVisible(GitBlit
+								.getBoolean(Keys.web.allowZipDownloads, true)).setEnabled(hasSubmodule));
+						item.add(links);						
 					} else {
 						// blob link
 						item.add(WicketUtils.getFileImage("pathIcon", entry.name));
diff --git a/tests/com/gitblit/tests/StringUtilsTest.java b/tests/com/gitblit/tests/StringUtilsTest.java
index 91bfa67..bcf3a99 100644
--- a/tests/com/gitblit/tests/StringUtilsTest.java
+++ b/tests/com/gitblit/tests/StringUtilsTest.java
@@ -150,4 +150,11 @@
 		assertFalse(StringUtils.fuzzyMatch("123", "12345"));
 		assertFalse(StringUtils.fuzzyMatch("AbCdEfHIJ", "abc*hhh"));
 	}
+	
+	@Test
+	public void testGetRepositoryPath() throws Exception {
+		assertEquals("gitblit/gitblit.git", StringUtils.extractRepositoryPath("git://github.com/gitblit/gitblit.git", new String [] { ".*?://github.com/(.*)" }));
+		assertEquals("gitblit.git", StringUtils.extractRepositoryPath("git://github.com/gitblit/gitblit.git", new String [] { ".*?://github.com/[^/].*?/(.*)" }));
+		assertEquals("gitblit.git", StringUtils.extractRepositoryPath("git://github.com/gitblit/gitblit.git"));
+	}
 }

--
Gitblit v1.9.1