James Moger
2013-08-14 f084f468756bde745d8e8e27c729f6e57bea5749
Implemented a graph servlet based on EGit/JGit's PlotWalk (issue-194)

The graph is generated server-side and therefore requires that the
commit table row height be fixed and match the row height of the
servlet. There will be layout misalignment if remotes refs are
displayed. Perhaps this can be improved in the future.

Change-Id: I39d0ffc7b1c3679976ce8c198c772ff86238f1a5
1 files added
8 files modified
474 ■■■■■ changed files
releases.moxie 2 ●●●●● patch | view | raw | blame | history
src/main/distrib/data/gitblit.properties 5 ●●●●● patch | view | raw | blame | history
src/main/java/WEB-INF/web.xml 13 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/BranchGraphServlet.java 363 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/Constants.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/LogPanel.html 24 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/LogPanel.java 19 ●●●●● patch | view | raw | blame | history
src/main/resources/gitblit.css 28 ●●●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/JGitUtilsTest.java 18 ●●●●● patch | view | raw | blame | history
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
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
#
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>
src/main/java/com/gitblit/BranchGraphServlet.java
New file
@@ -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;
        }
    }
}
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 = "***********************************************************";
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>
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) {
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;
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();
    }
}