From f084f468756bde745d8e8e27c729f6e57bea5749 Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Tue, 17 Sep 2013 17:34:00 -0400
Subject: [PATCH] Implemented a graph servlet based on EGit/JGit's PlotWalk (issue-194)

---
 src/main/java/com/gitblit/wicket/panels/LogPanel.java |   19 ++
 src/main/java/WEB-INF/web.xml                         |   13 +
 src/main/java/com/gitblit/Constants.java              |    2 
 src/main/resources/gitblit.css                        |   28 +++
 src/test/java/com/gitblit/tests/JGitUtilsTest.java    |   18 ++
 releases.moxie                                        |    2 
 src/main/distrib/data/gitblit.properties              |    5 
 src/main/java/com/gitblit/BranchGraphServlet.java     |  363 +++++++++++++++++++++++++++++++++++++++++++++
 src/main/java/com/gitblit/wicket/panels/LogPanel.html |   24 ++
 9 files changed, 467 insertions(+), 7 deletions(-)

diff --git a/releases.moxie b/releases.moxie
index 53ad8b5..74cf1e3 100644
--- a/releases.moxie
+++ b/releases.moxie
@@ -15,10 +15,12 @@
 	- Personal repository prefix (~) is now configurable (issue-265)
 	- Updated default binary and Lucene ignore extensions
     additions:
+	- Added branch graph image servlet based on EGit's branch graph renderer (issue-194)
 	- Added setting to control creating a repository as --shared on Unix servers (issue-263)
     dependencyChanges: ~
     settings:
     - { name: 'git.createRepositoriesShared', defaultValue: 'false' }
+	- { name: 'web.showBranchGraph', defaultValue: 'true' }
     contributors:
 	- James Moger
 	- Robin Rosenberg
diff --git a/src/main/distrib/data/gitblit.properties b/src/main/distrib/data/gitblit.properties
index 1fe1561..3c0f1d1 100644
--- a/src/main/distrib/data/gitblit.properties
+++ b/src/main/distrib/data/gitblit.properties
@@ -900,6 +900,11 @@
 # SINCE 0.5.0 
 web.generateActivityGraph = true
 
+# Displays the commits branch graph in the summary page and commits/log page.
+#
+# SINCE 1.4.0
+web.showBranchGraph = true
+
 # The default number of days to show on the activity page.
 # Value must exceed 0 else default of 7 is used
 #
diff --git a/src/main/java/WEB-INF/web.xml b/src/main/java/WEB-INF/web.xml
index cf71465..d4acb04 100644
--- a/src/main/java/WEB-INF/web.xml
+++ b/src/main/java/WEB-INF/web.xml
@@ -154,6 +154,17 @@
 		<url-pattern>/logo.png</url-pattern>
 	</servlet-mapping>
 
+	<!-- Branch Graph Servlet
+		 <url-pattern> MUST match: 
+			* Wicket Filter ignorePaths parameter -->
+	<servlet>
+		<servlet-name>BranchGraphServlet</servlet-name>
+		<servlet-class>com.gitblit.BranchGraphServlet</servlet-class>
+	</servlet>
+	<servlet-mapping>
+		<servlet-name>BranchGraphServlet</servlet-name>		
+		<url-pattern>/graph/*</url-pattern>
+	</servlet-mapping>
 
 	<!-- Robots.txt Servlet
 		 <url-pattern> MUST match: 
@@ -282,7 +293,7 @@
              	* PagesFilter <url-pattern>
              	* PagesServlet <url-pattern>
              	* com.gitblit.Constants.PAGES_PATH -->
-            <param-value>git/,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,sparkleshare/</param-value>
+            <param-value>git/,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/</param-value>
         </init-param>
     </filter>
     <filter-mapping>
diff --git a/src/main/java/com/gitblit/BranchGraphServlet.java b/src/main/java/com/gitblit/BranchGraphServlet.java
new file mode 100644
index 0000000..8fca455
--- /dev/null
+++ b/src/main/java/com/gitblit/BranchGraphServlet.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
+ * Copyright 2013 gitblit.com.
+ * and other copyright owners as documented in the project's IP log.
+ *
+ * This program and the accompanying materials are made available
+ * under the terms of the Eclipse Distribution License v1.0 which
+ * accompanies this distribution, is reproduced below, and is
+ * available at http://www.eclipse.org/org/documents/edl-v10.php
+ *
+ * All rights reserved.
+ *
+ * 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;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.Stroke;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import javax.imageio.ImageIO;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revplot.AbstractPlotRenderer;
+import org.eclipse.jgit.revplot.PlotCommit;
+import org.eclipse.jgit.revplot.PlotCommitList;
+import org.eclipse.jgit.revplot.PlotLane;
+import org.eclipse.jgit.revplot.PlotWalk;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+import com.gitblit.utils.JGitUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Handles requests for branch graphs
+ * 
+ * @author James Moger
+ * 
+ */
+public class BranchGraphServlet extends HttpServlet {
+
+	private static final long serialVersionUID = 1L;
+
+	private static final int LANE_WIDTH = 14;
+
+	// must match tr.commit css height
+	private static final int ROW_HEIGHT = 24;
+
+	private static final int RIGHT_PAD = 2;
+
+	private final Stroke[] strokeCache;
+
+	public BranchGraphServlet() {
+		super();
+
+		strokeCache = new Stroke[4];
+		for (int i = 1; i < strokeCache.length; i++)
+			strokeCache[i] = new BasicStroke(i);
+	}
+
+	/**
+	 * Returns an url to this servlet for the specified parameters.
+	 * 
+	 * @param baseURL
+	 * @param repository
+	 * @param objectId
+	 * @param numberCommits
+	 * @return an url
+	 */
+	public static String asLink(String baseURL, String repository, String objectId, int numberCommits) {
+		if (baseURL.length() > 0 && baseURL.charAt(baseURL.length() - 1) == '/') {
+			baseURL = baseURL.substring(0, baseURL.length() - 1);
+		}
+		return baseURL + Constants.BRANCH_GRAPH_PATH + "?r=" + repository
+				+ (objectId == null ? "" : ("&h=" + objectId))
+				+ (numberCommits > 0 ? ("&l=" + numberCommits) : "");
+	}
+
+	@Override
+	protected long getLastModified(HttpServletRequest req) {
+		String repository = req.getParameter("r");
+		String objectId = req.getParameter("h");
+		Repository r = null;
+		try {
+			r = GitBlit.self().getRepository(repository);
+			if (StringUtils.isEmpty(objectId)) {
+				objectId = JGitUtils.getHEADRef(r);
+			}
+			RevCommit commit = JGitUtils.getCommit(r, objectId);
+			return JGitUtils.getCommitDate(commit).getTime();
+		} finally {
+			if (r != null) {
+				r.close();
+			}
+		}
+	}
+
+	@Override
+	protected void doGet(HttpServletRequest request, HttpServletResponse response)
+			throws ServletException, IOException {
+		InputStream is = null;
+		Repository r = null;
+		PlotWalk rw = null;
+		try {
+			String repository = request.getParameter("r");
+			String objectId = request.getParameter("h");
+			String length = request.getParameter("l");
+
+			r = GitBlit.self().getRepository(repository);
+
+			rw = new PlotWalk(r);
+			if (StringUtils.isEmpty(objectId)) {
+				objectId = JGitUtils.getHEADRef(r);
+			}
+
+			rw.markStart(rw.lookupCommit(r.resolve(objectId)));
+
+			// default to the items-per-page setting, unless specified
+			int maxCommits = GitBlit.getInteger(Keys.web.itemsPerPage, 50);
+			if (!StringUtils.isEmpty(length)) {
+				int l = Integer.parseInt(length);
+				if (l > 0) {
+					maxCommits = l;
+				}
+			}
+
+			// fetch the requested commits plus some extra so that the last
+			// commit displayed *likely* has correct lane assignments  
+			CommitList commitList = new CommitList();
+			commitList.source(rw);
+			commitList.fillTo(2*maxCommits);
+
+			// determine the appropriate width for the image
+			int numLanes = 0;
+			int numCommits = Math.min(maxCommits, commitList.size());			
+			for (int i = 0; i < numCommits; i++) {
+				PlotCommit<Lane> commit = commitList.get(i);
+				int pos = commit.getLane().getPosition();
+				numLanes = Math.max(numLanes, pos + 1);
+			}
+
+			int graphWidth = numLanes * LANE_WIDTH + RIGHT_PAD;
+			int rowHeight = ROW_HEIGHT;
+
+			// create an image buffer and render the lanes
+			BufferedImage image = new BufferedImage(graphWidth, rowHeight*numCommits, BufferedImage.TYPE_INT_ARGB);
+			Graphics2D g = null;
+			try {
+				g = image.createGraphics();
+				g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+				LanesRenderer renderer = new LanesRenderer();
+				for (int i = 0; i < numCommits; i++) {
+					PlotCommit<Lane> commit = commitList.get(i);
+					Graphics row = g.create(0, i*rowHeight, graphWidth, rowHeight);
+					try {
+						renderer.paint(row, commit, rowHeight, graphWidth);
+					} finally {
+						row.dispose();
+						row = null;
+					}
+				}
+			} finally {
+				if (g != null) {
+					g.dispose();
+					g = null;
+				}
+			}
+
+			// write the image buffer to the client
+			response.setContentType("image/png");
+			if (numCommits > 0) {
+				response.setHeader("Cache-Control", "public, max-age=60, must-revalidate");
+				response.setDateHeader("Last-Modified", JGitUtils.getCommitDate(commitList.get(0)).getTime());
+			}
+			OutputStream os = response.getOutputStream();
+			ImageIO.write(image, "png", os);
+			os.flush();
+			image.flush();
+			image = null;
+		} catch (Exception e) {
+			e.printStackTrace();
+		} finally {
+			if (is != null) {
+				is.close();
+				is = null;
+			}
+			if (rw != null) {
+				rw.dispose();
+				rw = null;
+			}
+			if (r != null) {
+				r.close();
+				r = null;
+			}
+		}
+	}
+
+	private Stroke stroke(final int width) {
+		if (width < strokeCache.length)
+			return strokeCache[width];
+		return new BasicStroke(width);
+	}
+
+	static class CommitList extends PlotCommitList<Lane> {
+		final List<Color> laneColors;
+		final LinkedList<Color> colors;
+
+		CommitList() {
+			laneColors = new ArrayList<Color>();
+			laneColors.add(new Color(133, 166, 214));
+			laneColors.add(new Color(221, 205, 93));
+			laneColors.add(new Color(199, 134, 57));
+			laneColors.add(new Color(131, 150, 98));
+			laneColors.add(new Color(197, 123, 127));
+			laneColors.add(new Color(139, 136, 140));
+			laneColors.add(new Color(48, 135, 144));
+			laneColors.add(new Color(190, 93, 66));
+			laneColors.add(new Color(143, 163, 54));
+			laneColors.add(new Color(180, 148, 74));
+			laneColors.add(new Color(101, 101, 217));
+			laneColors.add(new Color(72, 153, 119));
+			laneColors.add(new Color(23, 101, 160));
+			laneColors.add(new Color(132, 164, 118));
+			laneColors.add(new Color(255, 230, 59));
+			laneColors.add(new Color(136, 176, 70));
+			laneColors.add(new Color(255, 138, 1));
+			laneColors.add(new Color(123, 187, 95));
+			laneColors.add(new Color(233, 88, 98));
+			laneColors.add(new Color(93, 158, 254));
+			laneColors.add(new Color(175, 215, 0));
+			laneColors.add(new Color(140, 134, 142));
+			laneColors.add(new Color(232, 168, 21));
+			laneColors.add(new Color(0, 172, 191));
+			laneColors.add(new Color(251, 58, 4));
+			laneColors.add(new Color(63, 64, 255));
+			laneColors.add(new Color(27, 194, 130));
+			laneColors.add(new Color(0, 104, 183));
+
+			colors = new LinkedList<Color>();
+			repackColors();
+		}
+
+		private void repackColors() {
+			colors.addAll(laneColors);
+		}
+
+		@Override
+		protected Lane createLane() {
+			final Lane lane = new Lane();
+			if (colors.isEmpty())
+				repackColors();
+			lane.color = colors.removeFirst();
+			return lane;
+		}
+
+		@Override
+		protected void recycleLane(final Lane lane) {
+			colors.add(lane.color);
+		}
+	}
+
+	static class Lane extends PlotLane {
+
+		private static final long serialVersionUID = 1L;
+
+		Color color;
+
+		@Override
+		public boolean equals(Object o) {
+			return super.equals(o) && color.equals(((Lane)o).color);
+		}
+
+		@Override
+		public int hashCode() {
+			return super.hashCode() ^ color.hashCode();
+		}
+	}
+
+	class LanesRenderer extends AbstractPlotRenderer<Lane, Color> implements Serializable {
+
+		private static final long serialVersionUID = 1L;
+
+		final Color commitDotFill = new Color(220, 220, 220);
+
+		final Color commitDotOutline = new Color(110, 110, 110);
+
+		transient Graphics2D g;
+
+		void paint(Graphics in, PlotCommit<Lane> commit, int h, int w) {
+			g = (Graphics2D) in.create();
+			try {
+				if (commit != null)
+					paintCommit(commit, h);
+			} finally {
+				g.dispose();
+				g = null;
+			}
+		}
+
+		@Override
+		protected void drawLine(Color color, int x1, int y1, int x2, int y2, int width) {
+			if (y1 == y2) {
+				x1 -= width / 2;
+				x2 -= width / 2;
+			} else if (x1 == x2) {
+				y1 -= width / 2;
+				y2 -= width / 2;
+			}
+
+			g.setColor(color);
+			g.setStroke(stroke(width));
+			g.drawLine(x1, y1, x2, y2);
+		}
+
+		@Override
+		protected void drawCommitDot(int x, int y, int w, int h) {
+			g.setColor(commitDotFill);
+			g.setStroke(strokeCache[2]);
+			g.fillOval(x + 2, y + 1, w - 2, h - 2);
+			g.setColor(commitDotOutline);
+			g.drawOval(x + 2, y + 1, w - 2, h - 2);
+		}
+
+		@Override
+		protected void drawBoundaryDot(int x, int y, int w, int h) {
+			drawCommitDot(x, y, w, h);
+		}
+
+		@Override
+		protected void drawText(String msg, int x, int y) {
+		}
+
+		@Override
+		protected Color laneColor(Lane myLane) {
+			return myLane != null ? myLane.color : Color.black;
+		}
+
+		@Override
+		protected int drawLabel(int x, int y, Ref ref) {
+			return 0;
+		}
+	}
+}
diff --git a/src/main/java/com/gitblit/Constants.java b/src/main/java/com/gitblit/Constants.java
index a3a3c70..88a1022 100644
--- a/src/main/java/com/gitblit/Constants.java
+++ b/src/main/java/com/gitblit/Constants.java
@@ -63,6 +63,8 @@
 	public static final String PAGES = "/pages/";
 	
 	public static final String SPARKLESHARE_INVITE_PATH = "/sparkleshare/";
+	
+	public static final String BRANCH_GRAPH_PATH = "/graph/";
 
 	public static final String BORDER = "***********************************************************";
 
diff --git a/src/main/java/com/gitblit/wicket/panels/LogPanel.html b/src/main/java/com/gitblit/wicket/panels/LogPanel.html
index 1abda87..fde9a3e 100644
--- a/src/main/java/com/gitblit/wicket/panels/LogPanel.html
+++ b/src/main/java/com/gitblit/wicket/panels/LogPanel.html
@@ -11,13 +11,29 @@
 	<div class="header"><i class="icon-refresh"></i> <b><span wicket:id="header">[log header]</span></b></div>
 	<table class="pretty">
 		<tbody>
-       		<tr wicket:id="commit">
+			<tr class="hidden-phone hidden-tablet">
+				<td wicket:id="graph" class="graph"><img wicket:id="image"></img></td>
+				<td></td>
+				<td></td>
+				<td></td>
+				<td></td>
+				<td></td>
+				<td></td>
+			</tr>
+       		<tr class="commit" wicket:id="commit">
          		<td class="date" style="width:6em;"><span wicket:id="commitDate">[commit date]</span></td>
-         		<td class="hidden-phone author"><span wicket:id="commitAuthor">[commit author]</span></td>
+         		<td class="hidden-phone author ellipsize"><span wicket:id="commitAuthor">[commit author]</span></td>
          		<td class="hidden-phone icon"><img wicket:id="commitIcon" /></td>
-         		<td class="message"><table class="nestedTable"><tr><td><span style="vertical-align:middle;" wicket:id="commitShortMessage">[commit short message]</span></td><td><div style="text-align:right;" wicket:id="commitRefs">[commit refs]</div></td></tr></table></td>
+         		<td class="message ellipsize">
+         			<table class="nestedTable">
+         				<tr>
+         					<td class="ellipsize"><span style="vertical-align:middle;" wicket:id="commitShortMessage">[commit short message]</span></td>
+         					<td><div style="text-align:right;" wicket:id="commitRefs">[commit refs]</div></td>
+         				</tr>
+         			</table>
+         		</td>
          		<td class="hidden-phone hidden-tablet rightAlign"><span wicket:id="hashLink">[hash link]</span></td>
-         		<td class="hidden-phone hidden-tablet rightAlign">
+         		<td class="hidden-phone hidden-tablet rightAlign" style="white-space: nowrap;">
          			<span class="link">
 						<a wicket:id="diff"><wicket:message key="gb.diff"></wicket:message></a> | <a wicket:id="tree"><wicket:message key="gb.tree"></wicket:message></a>
 					</span>
diff --git a/src/main/java/com/gitblit/wicket/panels/LogPanel.java b/src/main/java/com/gitblit/wicket/panels/LogPanel.java
index 6c523be..a8f3d55 100644
--- a/src/main/java/com/gitblit/wicket/panels/LogPanel.java
+++ b/src/main/java/com/gitblit/wicket/panels/LogPanel.java
@@ -19,6 +19,9 @@
 import java.util.List;
 import java.util.Map;
 
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.behavior.SimpleAttributeModifier;
+import org.apache.wicket.markup.html.WebMarkupContainer;
 import org.apache.wicket.markup.html.basic.Label;
 import org.apache.wicket.markup.html.link.BookmarkablePageLink;
 import org.apache.wicket.markup.repeater.Item;
@@ -32,9 +35,11 @@
 import com.gitblit.Constants;
 import com.gitblit.GitBlit;
 import com.gitblit.Keys;
+import com.gitblit.BranchGraphServlet;
 import com.gitblit.models.RefModel;
 import com.gitblit.utils.JGitUtils;
 import com.gitblit.utils.StringUtils;
+import com.gitblit.wicket.ExternalImage;
 import com.gitblit.wicket.WicketUtils;
 import com.gitblit.wicket.pages.CommitDiffPage;
 import com.gitblit.wicket.pages.CommitPage;
@@ -70,6 +75,20 @@
 		// inaccurate way to determine if there are more commits.
 		// works unless commits.size() represents the exact end.
 		hasMore = commits.size() >= itemsPerPage;
+		
+		final String baseUrl = WicketUtils.getGitblitURL(getRequest());
+		final boolean showGraph = GitBlit.getBoolean(Keys.web.showBranchGraph, true);
+		
+		MarkupContainer graph = new WebMarkupContainer("graph");
+		add(graph);
+		if (!showGraph || commits.isEmpty()) {
+			// not showing or nothing to show
+			graph.setVisible(false);
+		} else {
+			// set the rowspan on the graph row and +1 for the graph row itself
+			graph.add(new SimpleAttributeModifier("rowspan", "" + (commits.size() + 1)));
+			graph.add(new ExternalImage("image", BranchGraphServlet.asLink(baseUrl, repositoryName, commits.get(0).name(), commits.size())));
+		}
 
 		// header
 		if (pageResults) {
diff --git a/src/main/resources/gitblit.css b/src/main/resources/gitblit.css
index 4db1548..d8745bf 100644
--- a/src/main/resources/gitblit.css
+++ b/src/main/resources/gitblit.css
@@ -1146,6 +1146,32 @@
 	margin-bottom: 0px !important;
 }
 
+table.pretty td.graph {
+	border-right: 1px solid #ddd;
+	border-bottom: 1px solid #ddd;
+}
+
+table.pretty tr.commit {
+	/* must match branch graph servlet row height definition */
+	height: 24px;
+}
+
+@media (min-width: 768px) {
+  td.ellipsize {
+	text-overflow: ellipsis;
+	overflow: hidden;
+	white-space: nowrap;
+  }
+}
+
+@media (max-width: 767px) {
+  td.ellipsize {
+    text-overflow: inherit;
+    overflow: visible;
+    white-space: wrap;
+  }	
+}
+
 table.comments td {
 	padding: 4px;
 	line-height: 17px;
@@ -1204,7 +1230,7 @@
 	font-weight: bold; 
 	background-color: #ffffff !important;
 	padding-top: 0px !important;
-	margin-bottom: 0 !imporant;	
+	margin-bottom: 0 !important;	
 	border: 0 !important;
 	border-radius: 0 !important;
 	line-height: 1em;
diff --git a/src/test/java/com/gitblit/tests/JGitUtilsTest.java b/src/test/java/com/gitblit/tests/JGitUtilsTest.java
index 463c0a8..06fd674 100644
--- a/src/test/java/com/gitblit/tests/JGitUtilsTest.java
+++ b/src/test/java/com/gitblit/tests/JGitUtilsTest.java
@@ -37,6 +37,10 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.RepositoryCache;
 import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.revplot.PlotCommit;
+import org.eclipse.jgit.revplot.PlotCommitList;
+import org.eclipse.jgit.revplot.PlotLane;
+import org.eclipse.jgit.revplot.PlotWalk;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.util.FS;
@@ -602,5 +606,17 @@
 		assertTrue(zipFileB.length() > 0);
 		zipFileB.delete();
 	}
-
+	
+	@Test
+	public void testPlots() throws Exception {
+		Repository repository = GitBlitSuite.getTicgitRepository();
+		PlotWalk pw = new PlotWalk(repository);
+		PlotCommitList<PlotLane> commits = new PlotCommitList<PlotLane>();
+		commits.source(pw);
+		commits.fillTo(25);
+		for (PlotCommit<PlotLane> commit : commits) {
+			System.out.println(commit);
+		}
+		repository.close();
+	}
 }
\ No newline at end of file

--
Gitblit v1.9.1