summaryrefslogtreecommitdiffabout
authorLars Hjemli <hjemli@gmail.com>2008-12-06 16:38:19 (UTC)
committer Lars Hjemli <hjemli@gmail.com>2008-12-06 16:38:19 (UTC)
commitf86a23ff537258d36bf8f1876fa7a4bede6673d8 (patch) (side-by-side diff)
tree8328d415416058cdc5b0fd2c6564ddcab5766c7a
parent140012d7a8e51df5a9f9c556696778b86ade4fc9 (diff)
downloadcgit-f86a23ff537258d36bf8f1876fa7a4bede6673d8.zip
cgit-f86a23ff537258d36bf8f1876fa7a4bede6673d8.tar.gz
cgit-f86a23ff537258d36bf8f1876fa7a4bede6673d8.tar.bz2
Add a 'stats' page to each repo
This new page, which is disabled by default, can be used to print some statistics about the number of commits per period in the repository, where period can be either weeks, months, quarters or years. The function can be activated globally by setting 'enable-stats=1' in cgitrc and disabled for individual repos by setting 'repo.enable-stats=0'. Signed-off-by: Lars Hjemli <hjemli@gmail.com>
Diffstat (more/less context) (ignore whitespace changes)
-rw-r--r--Makefile1
-rw-r--r--cgit.c6
-rw-r--r--cgit.css77
-rw-r--r--cgit.h3
-rw-r--r--cgitrc.5.txt8
-rw-r--r--cmd.c10
-rw-r--r--shared.c1
-rw-r--r--ui-shared.c3
-rw-r--r--ui-stats.c380
-rw-r--r--ui-stats.h8
10 files changed, 497 insertions, 0 deletions
diff --git a/Makefile b/Makefile
index 561af76..f426f98 100644
--- a/Makefile
+++ b/Makefile
@@ -1,129 +1,130 @@
CGIT_VERSION = v0.8.1
CGIT_SCRIPT_NAME = cgit.cgi
CGIT_SCRIPT_PATH = /var/www/htdocs/cgit
CGIT_CONFIG = /etc/cgitrc
CACHE_ROOT = /var/cache/cgit
SHA1_HEADER = <openssl/sha.h>
GIT_VER = 1.6.0.2
GIT_URL = http://www.kernel.org/pub/software/scm/git/git-$(GIT_VER).tar.bz2
#
# Let the user override the above settings.
#
-include cgit.conf
#
# Define a way to invoke make in subdirs quietly, shamelessly ripped
# from git.git
#
QUIET_SUBDIR0 = +$(MAKE) -C # space to separate -C and subdir
QUIET_SUBDIR1 =
ifneq ($(findstring $(MAKEFLAGS),w),w)
PRINT_DIR = --no-print-directory
else # "make -w"
NO_SUBDIR = :
endif
ifndef V
QUIET_CC = @echo ' ' CC $@;
QUIET_MM = @echo ' ' MM $@;
QUIET_SUBDIR0 = +@subdir=
QUIET_SUBDIR1 = ;$(NO_SUBDIR) echo ' ' SUBDIR $$subdir; \
$(MAKE) $(PRINT_DIR) -C $$subdir
endif
#
# Define a pattern rule for automatic dependency building
#
%.d: %.c
$(QUIET_MM)$(CC) $(CFLAGS) -MM $< | sed -e 's/\($*\)\.o:/\1.o $@:/g' >$@
#
# Define a pattern rule for silent object building
#
%.o: %.c
$(QUIET_CC)$(CC) -o $*.o -c $(CFLAGS) $<
EXTLIBS = git/libgit.a git/xdiff/lib.a -lz -lcrypto
OBJECTS =
OBJECTS += cache.o
OBJECTS += cgit.o
OBJECTS += cmd.o
OBJECTS += configfile.o
OBJECTS += html.o
OBJECTS += parsing.o
OBJECTS += scan-tree.o
OBJECTS += shared.o
OBJECTS += ui-atom.o
OBJECTS += ui-blob.o
OBJECTS += ui-clone.o
OBJECTS += ui-commit.o
OBJECTS += ui-diff.o
OBJECTS += ui-log.o
OBJECTS += ui-patch.o
OBJECTS += ui-plain.o
OBJECTS += ui-refs.o
OBJECTS += ui-repolist.o
OBJECTS += ui-shared.o
OBJECTS += ui-snapshot.o
+OBJECTS += ui-stats.o
OBJECTS += ui-summary.o
OBJECTS += ui-tag.o
OBJECTS += ui-tree.o
ifdef NEEDS_LIBICONV
EXTLIBS += -liconv
endif
.PHONY: all libgit test install uninstall clean force-version get-git
all: cgit
VERSION: force-version
@./gen-version.sh "$(CGIT_VERSION)"
-include VERSION
CFLAGS += -g -Wall -Igit
CFLAGS += -DSHA1_HEADER='$(SHA1_HEADER)'
CFLAGS += -DCGIT_VERSION='"$(CGIT_VERSION)"'
CFLAGS += -DCGIT_CONFIG='"$(CGIT_CONFIG)"'
CFLAGS += -DCGIT_SCRIPT_NAME='"$(CGIT_SCRIPT_NAME)"'
CFLAGS += -DCGIT_CACHE_ROOT='"$(CACHE_ROOT)"'
ifdef NO_ICONV
CFLAGS += -DNO_ICONV
endif
cgit: $(OBJECTS) libgit
$(QUIET_CC)$(CC) $(CFLAGS) $(LDFLAGS) -o cgit $(OBJECTS) $(EXTLIBS)
cgit.o: VERSION
-include $(OBJECTS:.o=.d)
libgit:
$(QUIET_SUBDIR0)git $(QUIET_SUBDIR1) libgit.a
$(QUIET_SUBDIR0)git $(QUIET_SUBDIR1) xdiff/lib.a
test: all
$(QUIET_SUBDIR0)tests $(QUIET_SUBDIR1) all
install: all
mkdir -p $(DESTDIR)$(CGIT_SCRIPT_PATH)
install cgit $(DESTDIR)$(CGIT_SCRIPT_PATH)/$(CGIT_SCRIPT_NAME)
install cgit.css $(DESTDIR)$(CGIT_SCRIPT_PATH)/cgit.css
install cgit.png $(DESTDIR)$(CGIT_SCRIPT_PATH)/cgit.png
uninstall:
rm -f $(CGIT_SCRIPT_PATH)/$(CGIT_SCRIPT_NAME)
rm -f $(CGIT_SCRIPT_PATH)/cgit.css
rm -f $(CGIT_SCRIPT_PATH)/cgit.png
clean:
rm -f cgit VERSION *.o *.d
get-git:
curl $(GIT_URL) | tar -xj && rm -rf git && mv git-$(GIT_VER) git
diff --git a/cgit.c b/cgit.c
index c82587b..22b6d7c 100644
--- a/cgit.c
+++ b/cgit.c
@@ -1,252 +1,258 @@
/* cgit.c: cgi for the git scm
*
* Copyright (C) 2006 Lars Hjemli
*
* Licensed under GNU General Public License v2
* (see COPYING for full license text)
*/
#include "cgit.h"
#include "cache.h"
#include "cmd.h"
#include "configfile.h"
#include "html.h"
#include "ui-shared.h"
#include "scan-tree.h"
const char *cgit_version = CGIT_VERSION;
void config_cb(const char *name, const char *value)
{
if (!strcmp(name, "root-title"))
ctx.cfg.root_title = xstrdup(value);
else if (!strcmp(name, "root-desc"))
ctx.cfg.root_desc = xstrdup(value);
else if (!strcmp(name, "root-readme"))
ctx.cfg.root_readme = xstrdup(value);
else if (!strcmp(name, "css"))
ctx.cfg.css = xstrdup(value);
else if (!strcmp(name, "favicon"))
ctx.cfg.favicon = xstrdup(value);
else if (!strcmp(name, "footer"))
ctx.cfg.footer = xstrdup(value);
else if (!strcmp(name, "logo"))
ctx.cfg.logo = xstrdup(value);
else if (!strcmp(name, "index-header"))
ctx.cfg.index_header = xstrdup(value);
else if (!strcmp(name, "index-info"))
ctx.cfg.index_info = xstrdup(value);
else if (!strcmp(name, "logo-link"))
ctx.cfg.logo_link = xstrdup(value);
else if (!strcmp(name, "module-link"))
ctx.cfg.module_link = xstrdup(value);
else if (!strcmp(name, "virtual-root")) {
ctx.cfg.virtual_root = trim_end(value, '/');
if (!ctx.cfg.virtual_root && (!strcmp(value, "/")))
ctx.cfg.virtual_root = "";
} else if (!strcmp(name, "nocache"))
ctx.cfg.nocache = atoi(value);
else if (!strcmp(name, "snapshots"))
ctx.cfg.snapshots = cgit_parse_snapshots_mask(value);
else if (!strcmp(name, "enable-index-links"))
ctx.cfg.enable_index_links = atoi(value);
else if (!strcmp(name, "enable-log-filecount"))
ctx.cfg.enable_log_filecount = atoi(value);
else if (!strcmp(name, "enable-log-linecount"))
ctx.cfg.enable_log_linecount = atoi(value);
+ else if (!strcmp(name, "enable-stats"))
+ ctx.cfg.enable_stats = atoi(value);
else if (!strcmp(name, "cache-size"))
ctx.cfg.cache_size = atoi(value);
else if (!strcmp(name, "cache-root"))
ctx.cfg.cache_root = xstrdup(value);
else if (!strcmp(name, "cache-root-ttl"))
ctx.cfg.cache_root_ttl = atoi(value);
else if (!strcmp(name, "cache-repo-ttl"))
ctx.cfg.cache_repo_ttl = atoi(value);
else if (!strcmp(name, "cache-static-ttl"))
ctx.cfg.cache_static_ttl = atoi(value);
else if (!strcmp(name, "cache-dynamic-ttl"))
ctx.cfg.cache_dynamic_ttl = atoi(value);
else if (!strcmp(name, "max-message-length"))
ctx.cfg.max_msg_len = atoi(value);
else if (!strcmp(name, "max-repodesc-length"))
ctx.cfg.max_repodesc_len = atoi(value);
else if (!strcmp(name, "max-repo-count"))
ctx.cfg.max_repo_count = atoi(value);
else if (!strcmp(name, "max-commit-count"))
ctx.cfg.max_commit_count = atoi(value);
else if (!strcmp(name, "summary-log"))
ctx.cfg.summary_log = atoi(value);
else if (!strcmp(name, "summary-branches"))
ctx.cfg.summary_branches = atoi(value);
else if (!strcmp(name, "summary-tags"))
ctx.cfg.summary_tags = atoi(value);
else if (!strcmp(name, "agefile"))
ctx.cfg.agefile = xstrdup(value);
else if (!strcmp(name, "renamelimit"))
ctx.cfg.renamelimit = atoi(value);
else if (!strcmp(name, "robots"))
ctx.cfg.robots = xstrdup(value);
else if (!strcmp(name, "clone-prefix"))
ctx.cfg.clone_prefix = xstrdup(value);
else if (!strcmp(name, "local-time"))
ctx.cfg.local_time = atoi(value);
else if (!strcmp(name, "repo.group"))
ctx.cfg.repo_group = xstrdup(value);
else if (!strcmp(name, "repo.url"))
ctx.repo = cgit_add_repo(value);
else if (!strcmp(name, "repo.name"))
ctx.repo->name = xstrdup(value);
else if (ctx.repo && !strcmp(name, "repo.path"))
ctx.repo->path = trim_end(value, '/');
else if (ctx.repo && !strcmp(name, "repo.clone-url"))
ctx.repo->clone_url = xstrdup(value);
else if (ctx.repo && !strcmp(name, "repo.desc"))
ctx.repo->desc = xstrdup(value);
else if (ctx.repo && !strcmp(name, "repo.owner"))
ctx.repo->owner = xstrdup(value);
else if (ctx.repo && !strcmp(name, "repo.defbranch"))
ctx.repo->defbranch = xstrdup(value);
else if (ctx.repo && !strcmp(name, "repo.snapshots"))
ctx.repo->snapshots = ctx.cfg.snapshots & cgit_parse_snapshots_mask(value); /* XXX: &? */
else if (ctx.repo && !strcmp(name, "repo.enable-log-filecount"))
ctx.repo->enable_log_filecount = ctx.cfg.enable_log_filecount * atoi(value);
else if (ctx.repo && !strcmp(name, "repo.enable-log-linecount"))
ctx.repo->enable_log_linecount = ctx.cfg.enable_log_linecount * atoi(value);
+ else if (ctx.repo && !strcmp(name, "repo.enable-stats"))
+ ctx.repo->enable_stats = ctx.cfg.enable_stats && atoi(value);
else if (ctx.repo && !strcmp(name, "repo.module-link"))
ctx.repo->module_link= xstrdup(value);
else if (ctx.repo && !strcmp(name, "repo.readme") && value != NULL) {
if (*value == '/')
ctx.repo->readme = xstrdup(value);
else
ctx.repo->readme = xstrdup(fmt("%s/%s", ctx.repo->path, value));
} else if (!strcmp(name, "include"))
parse_configfile(value, config_cb);
}
static void querystring_cb(const char *name, const char *value)
{
if (!strcmp(name,"r")) {
ctx.qry.repo = xstrdup(value);
ctx.repo = cgit_get_repoinfo(value);
} else if (!strcmp(name, "p")) {
ctx.qry.page = xstrdup(value);
} else if (!strcmp(name, "url")) {
ctx.qry.url = xstrdup(value);
cgit_parse_url(value);
} else if (!strcmp(name, "qt")) {
ctx.qry.grep = xstrdup(value);
} else if (!strcmp(name, "q")) {
ctx.qry.search = xstrdup(value);
} else if (!strcmp(name, "h")) {
ctx.qry.head = xstrdup(value);
ctx.qry.has_symref = 1;
} else if (!strcmp(name, "id")) {
ctx.qry.sha1 = xstrdup(value);
ctx.qry.has_sha1 = 1;
} else if (!strcmp(name, "id2")) {
ctx.qry.sha2 = xstrdup(value);
ctx.qry.has_sha1 = 1;
} else if (!strcmp(name, "ofs")) {
ctx.qry.ofs = atoi(value);
} else if (!strcmp(name, "path")) {
ctx.qry.path = trim_end(value, '/');
} else if (!strcmp(name, "name")) {
ctx.qry.name = xstrdup(value);
} else if (!strcmp(name, "mimetype")) {
ctx.qry.mimetype = xstrdup(value);
+ } else if (!strcmp(name, "period")) {
+ ctx.qry.period = xstrdup(value);
}
}
static void prepare_context(struct cgit_context *ctx)
{
memset(ctx, 0, sizeof(ctx));
ctx->cfg.agefile = "info/web/last-modified";
ctx->cfg.nocache = 0;
ctx->cfg.cache_size = 0;
ctx->cfg.cache_dynamic_ttl = 5;
ctx->cfg.cache_max_create_time = 5;
ctx->cfg.cache_repo_ttl = 5;
ctx->cfg.cache_root = CGIT_CACHE_ROOT;
ctx->cfg.cache_root_ttl = 5;
ctx->cfg.cache_static_ttl = -1;
ctx->cfg.css = "/cgit.css";
ctx->cfg.logo = "/git-logo.png";
ctx->cfg.local_time = 0;
ctx->cfg.max_repo_count = 50;
ctx->cfg.max_commit_count = 50;
ctx->cfg.max_lock_attempts = 5;
ctx->cfg.max_msg_len = 80;
ctx->cfg.max_repodesc_len = 80;
ctx->cfg.module_link = "./?repo=%s&page=commit&id=%s";
ctx->cfg.renamelimit = -1;
ctx->cfg.robots = "index, nofollow";
ctx->cfg.root_title = "Git repository browser";
ctx->cfg.root_desc = "a fast webinterface for the git dscm";
ctx->cfg.script_name = CGIT_SCRIPT_NAME;
ctx->cfg.summary_branches = 10;
ctx->cfg.summary_log = 10;
ctx->cfg.summary_tags = 10;
ctx->page.mimetype = "text/html";
ctx->page.charset = PAGE_ENCODING;
ctx->page.filename = NULL;
ctx->page.size = 0;
ctx->page.modified = time(NULL);
ctx->page.expires = ctx->page.modified;
}
struct refmatch {
char *req_ref;
char *first_ref;
int match;
};
int find_current_ref(const char *refname, const unsigned char *sha1,
int flags, void *cb_data)
{
struct refmatch *info;
info = (struct refmatch *)cb_data;
if (!strcmp(refname, info->req_ref))
info->match = 1;
if (!info->first_ref)
info->first_ref = xstrdup(refname);
return info->match;
}
char *find_default_branch(struct cgit_repo *repo)
{
struct refmatch info;
char *ref;
info.req_ref = repo->defbranch;
info.first_ref = NULL;
info.match = 0;
for_each_branch_ref(find_current_ref, &info);
if (info.match)
ref = info.req_ref;
else
ref = info.first_ref;
if (ref)
ref = xstrdup(ref);
return ref;
}
static int prepare_repo_cmd(struct cgit_context *ctx)
{
char *tmp;
unsigned char sha1[20];
int nongit = 0;
setenv("GIT_DIR", ctx->repo->path, 1);
setup_git_directory_gently(&nongit);
if (nongit) {
ctx->page.title = fmt("%s - %s", ctx->cfg.root_title,
"config error");
tmp = fmt("Not a git repository: '%s'", ctx->repo->path);
ctx->repo = NULL;
cgit_print_http_headers(ctx);
cgit_print_docstart(ctx);
cgit_print_pageheader(ctx);
cgit_print_error(tmp);
cgit_print_docend();
return 1;
diff --git a/cgit.css b/cgit.css
index a37d218..ef30fbf 100644
--- a/cgit.css
+++ b/cgit.css
@@ -363,96 +363,173 @@ table.diff td div.head {
font-weight: bold;
margin-top: 1em;
color: black;
}
table.diff td div.hunk {
color: #009;
}
table.diff td div.add {
color: green;
}
table.diff td div.del {
color: red;
}
.sha1 {
font-family: monospace;
font-size: 90%;
}
.left {
text-align: left;
}
.right {
text-align: right;
}
table.list td.repogroup {
font-style: italic;
color: #888;
}
a.button {
font-size: 80%;
padding: 0em 0.5em;
}
a.primary {
font-size: 100%;
}
a.secondary {
font-size: 90%;
}
td.toplevel-repo {
}
table.list td.sublevel-repo {
padding-left: 1.5em;
}
div.pager {
text-align: center;
margin: 1em 0em 0em 0em;
}
div.pager a {
color: #777;
margin: 0em 0.5em;
}
span.age-mins {
font-weight: bold;
color: #080;
}
span.age-hours {
color: #080;
}
span.age-days {
color: #040;
}
span.age-weeks {
color: #444;
}
span.age-months {
color: #888;
}
span.age-years {
color: #bbb;
}
div.footer {
margin-top: 0.5em;
text-align: center;
font-size: 80%;
color: #ccc;
}
+table.stats {
+ border: solid 1px black;
+ border-collapse: collapse;
+}
+
+table.stats th {
+ text-align: left;
+ padding: 1px 0.5em;
+ background-color: #eee;
+ border: solid 1px black;
+}
+
+table.stats td {
+ text-align: right;
+ padding: 1px 0.5em;
+ border: solid 1px black;
+}
+
+table.stats td.total {
+ font-weight: bold;
+ text-align: left;
+}
+
+table.stats td.sum {
+ color: #c00;
+ font-weight: bold;
+/* background-color: #eee; */
+}
+
+table.stats td.left {
+ text-align: left;
+}
+
+table.vgraph {
+ border-collapse: separate;
+ border: solid 1px black;
+ height: 200px;
+}
+
+table.vgraph th {
+ background-color: #eee;
+ font-weight: bold;
+ border: solid 1px white;
+ padding: 1px 0.5em;
+}
+
+table.vgraph td {
+ vertical-align: bottom;
+ padding: 0px 10px;
+}
+
+table.vgraph div.bar {
+ background-color: #eee;
+}
+
+table.hgraph {
+ border: solid 1px black;
+ width: 800px;
+}
+
+table.hgraph th {
+ background-color: #eee;
+ font-weight: bold;
+ border: solid 1px black;
+ padding: 1px 0.5em;
+}
+
+table.hgraph td {
+ vertical-align: center;
+ padding: 2px 2px;
+}
+
+table.hgraph div.bar {
+ background-color: #eee;
+ height: 1em;
+}
+
diff --git a/cgit.h b/cgit.h
index 91db98a..85045c4 100644
--- a/cgit.h
+++ b/cgit.h
@@ -1,243 +1,246 @@
#ifndef CGIT_H
#define CGIT_H
#include <git-compat-util.h>
#include <cache.h>
#include <grep.h>
#include <object.h>
#include <tree.h>
#include <commit.h>
#include <tag.h>
#include <diff.h>
#include <diffcore.h>
#include <refs.h>
#include <revision.h>
#include <log-tree.h>
#include <archive.h>
#include <xdiff/xdiff.h>
#include <utf8.h>
/*
* Dateformats used on misc. pages
*/
#define FMT_LONGDATE "%Y-%m-%d %H:%M:%S (%Z)"
#define FMT_SHORTDATE "%Y-%m-%d"
#define FMT_ATOMDATE "%Y-%m-%dT%H:%M:%SZ"
/*
* Limits used for relative dates
*/
#define TM_MIN 60
#define TM_HOUR (TM_MIN * 60)
#define TM_DAY (TM_HOUR * 24)
#define TM_WEEK (TM_DAY * 7)
#define TM_YEAR (TM_DAY * 365)
#define TM_MONTH (TM_YEAR / 12.0)
/*
* Default encoding
*/
#define PAGE_ENCODING "UTF-8"
typedef void (*configfn)(const char *name, const char *value);
typedef void (*filepair_fn)(struct diff_filepair *pair);
typedef void (*linediff_fn)(char *line, int len);
struct cgit_repo {
char *url;
char *name;
char *path;
char *desc;
char *owner;
char *defbranch;
char *group;
char *module_link;
char *readme;
char *clone_url;
int snapshots;
int enable_log_filecount;
int enable_log_linecount;
+ int enable_stats;
};
struct cgit_repolist {
int length;
int count;
struct cgit_repo *repos;
};
struct commitinfo {
struct commit *commit;
char *author;
char *author_email;
unsigned long author_date;
char *committer;
char *committer_email;
unsigned long committer_date;
char *subject;
char *msg;
char *msg_encoding;
};
struct taginfo {
char *tagger;
char *tagger_email;
unsigned long tagger_date;
char *msg;
};
struct refinfo {
const char *refname;
struct object *object;
union {
struct taginfo *tag;
struct commitinfo *commit;
};
};
struct reflist {
struct refinfo **refs;
int alloc;
int count;
};
struct cgit_query {
int has_symref;
int has_sha1;
char *raw;
char *repo;
char *page;
char *search;
char *grep;
char *head;
char *sha1;
char *sha2;
char *path;
char *name;
char *mimetype;
char *url;
+ char *period;
int ofs;
int nohead;
};
struct cgit_config {
char *agefile;
char *cache_root;
char *clone_prefix;
char *css;
char *favicon;
char *footer;
char *index_header;
char *index_info;
char *logo;
char *logo_link;
char *module_link;
char *repo_group;
char *robots;
char *root_title;
char *root_desc;
char *root_readme;
char *script_name;
char *virtual_root;
int cache_size;
int cache_dynamic_ttl;
int cache_max_create_time;
int cache_repo_ttl;
int cache_root_ttl;
int cache_static_ttl;
int enable_index_links;
int enable_log_filecount;
int enable_log_linecount;
+ int enable_stats;
int local_time;
int max_repo_count;
int max_commit_count;
int max_lock_attempts;
int max_msg_len;
int max_repodesc_len;
int nocache;
int renamelimit;
int snapshots;
int summary_branches;
int summary_log;
int summary_tags;
};
struct cgit_page {
time_t modified;
time_t expires;
size_t size;
char *mimetype;
char *charset;
char *filename;
char *title;
};
struct cgit_context {
struct cgit_query qry;
struct cgit_config cfg;
struct cgit_repo *repo;
struct cgit_page page;
};
struct cgit_snapshot_format {
const char *suffix;
const char *mimetype;
write_archive_fn_t write_func;
int bit;
};
extern const char *cgit_version;
extern struct cgit_repolist cgit_repolist;
extern struct cgit_context ctx;
extern const struct cgit_snapshot_format cgit_snapshot_formats[];
extern struct cgit_repo *cgit_add_repo(const char *url);
extern struct cgit_repo *cgit_get_repoinfo(const char *url);
extern void cgit_repo_config_cb(const char *name, const char *value);
extern int chk_zero(int result, char *msg);
extern int chk_positive(int result, char *msg);
extern int chk_non_negative(int result, char *msg);
extern char *trim_end(const char *str, char c);
extern char *strlpart(char *txt, int maxlen);
extern char *strrpart(char *txt, int maxlen);
extern void cgit_add_ref(struct reflist *list, struct refinfo *ref);
extern int cgit_refs_cb(const char *refname, const unsigned char *sha1,
int flags, void *cb_data);
extern void *cgit_free_commitinfo(struct commitinfo *info);
extern int cgit_diff_files(const unsigned char *old_sha1,
const unsigned char *new_sha1,
linediff_fn fn);
extern void cgit_diff_tree(const unsigned char *old_sha1,
const unsigned char *new_sha1,
filepair_fn fn, const char *prefix);
extern void cgit_diff_commit(struct commit *commit, filepair_fn fn);
extern char *fmt(const char *format,...);
extern struct commitinfo *cgit_parse_commit(struct commit *commit);
extern struct taginfo *cgit_parse_tag(struct tag *tag);
extern void cgit_parse_url(const char *url);
extern const char *cgit_repobasename(const char *reponame);
extern int cgit_parse_snapshots_mask(const char *str);
/* libgit.a either links against or compiles its own implementation of
* strcasestr(), and we'd like to reuse it. Simply re-declaring it
* seems to do the trick.
*/
extern char *strcasestr(const char *haystack, const char *needle);
#endif /* CGIT_H */
diff --git a/cgitrc.5.txt b/cgitrc.5.txt
index 7887b02..60d3ea4 100644
--- a/cgitrc.5.txt
+++ b/cgitrc.5.txt
@@ -1,316 +1,324 @@
CGITRC
======
NAME
----
cgitrc - runtime configuration for cgit
DESCRIPTION
-----------
Cgitrc contains all runtime settings for cgit, including the list of git
repositories, formatted as a line-separated list of NAME=VALUE pairs. Blank
lines, and lines starting with '#', are ignored.
GLOBAL SETTINGS
---------------
agefile
Specifies a path, relative to each repository path, which can be used
to specify the date and time of the youngest commit in the repository.
The first line in the file is used as input to the "parse_date"
function in libgit. Recommended timestamp-format is "yyyy-mm-dd
hh:mm:ss". Default value: "info/web/last-modified".
cache-root
Path used to store the cgit cache entries. Default value:
"/var/cache/cgit".
cache-dynamic-ttl
Number which specifies the time-to-live, in minutes, for the cached
version of repository pages accessed without a fixed SHA1. Default
value: "5".
cache-repo-ttl
Number which specifies the time-to-live, in minutes, for the cached
version of the repository summary page. Default value: "5".
cache-root-ttl
Number which specifies the time-to-live, in minutes, for the cached
version of the repository index page. Default value: "5".
cache-size
The maximum number of entries in the cgit cache. Default value: "0"
(i.e. caching is disabled).
cache-static-ttl
Number which specifies the time-to-live, in minutes, for the cached
version of repository pages accessed with a fixed SHA1. Default value:
"5".
clone-prefix
Space-separated list of common prefixes which, when combined with a
repository url, generates valid clone urls for the repository. This
setting is only used if `repo.clone-url` is unspecified. Default value:
none.
css
Url which specifies the css document to include in all cgit pages.
Default value: "/cgit.css".
enable-index-links
Flag which, when set to "1", will make cgit generate extra links for
each repo in the repository index (specifically, to the "summary",
"commit" and "tree" pages). Default value: "0".
enable-log-filecount
Flag which, when set to "1", will make cgit print the number of
modified files for each commit on the repository log page. Default
value: "0".
enable-log-linecount
Flag which, when set to "1", will make cgit print the number of added
and removed lines for each commit on the repository log page. Default
value: "0".
+enable-stats
+ Globally enable/disable statistics for each repository. Default
+ value: "0".
+
favicon
Url used as link to a shortcut icon for cgit. If specified, it is
suggested to use the value "/favicon.ico" since certain browsers will
ignore other values. Default value: none.
footer
The content of the file specified with this option will be included
verbatim at the bottom of all pages (i.e. it replaces the standard
"generated by..." message. Default value: none.
include
Name of a configfile to include before the rest of the current config-
file is parsed. Default value: none.
index-header
The content of the file specified with this option will be included
verbatim above the repository index. This setting is deprecated, and
will not be supported by cgit-1.0 (use root-readme instead). Default
value: none.
index-info
The content of the file specified with this option will be included
verbatim below the heading on the repository index page. This setting
is deprecated, and will not be supported by cgit-1.0 (use root-desc
instead). Default value: none.
local-time
Flag which, if set to "1", makes cgit print commit and tag times in the
servers timezone. Default value: "0".
logo
Url which specifies the source of an image which will be used as a logo
on all cgit pages.
logo-link
Url loaded when clicking on the cgit logo image. If unspecified the
calculated url of the repository index page will be used. Default
value: none.
max-commit-count
Specifies the number of entries to list per page in "log" view. Default
value: "50".
max-message-length
Specifies the maximum number of commit message characters to display in
"log" view. Default value: "80".
max-repo-count
Specifies the number of entries to list per page on the repository
index page. Default value: "50".
max-repodesc-length
Specifies the maximum number of repo description characters to display
on the repository index page. Default value: "80".
module-link
Text which will be used as the formatstring for a hyperlink when a
submodule is printed in a directory listing. The arguments for the
formatstring are the path and SHA1 of the submodule commit. Default
value: "./?repo=%s&page=commit&id=%s"
nocache
If set to the value "1" caching will be disabled. This settings is
deprecated, and will not be honored starting with cgit-1.0. Default
value: "0".
renamelimit
Maximum number of files to consider when detecting renames. The value
"-1" uses the compiletime value in git (for further info, look at
`man git-diff`). Default value: "-1".
repo.group
A value for the current repository group, which all repositories
specified after this setting will inherit. Default value: none.
robots
Text used as content for the "robots" meta-tag. Default value:
"index, nofollow".
root-desc
Text printed below the heading on the repository index page. Default
value: "a fast webinterface for the git dscm".
root-readme:
The content of the file specified with this option will be included
verbatim below the "about" link on the repository index page. Default
value: none.
root-title
Text printed as heading on the repository index page. Default value:
"Git Repository Browser".
snapshots
Text which specifies the default (and allowed) set of snapshot formats
supported by cgit. The value is a space-separated list of zero or more
of the following values:
"tar" uncompressed tar-file
"tar.gz" gzip-compressed tar-file
"tar.bz2" bzip-compressed tar-file
"zip" zip-file
Default value: none.
summary-branches
Specifies the number of branches to display in the repository "summary"
view. Default value: "10".
summary-log
Specifies the number of log entries to display in the repository
"summary" view. Default value: "10".
summary-tags
Specifies the number of tags to display in the repository "summary"
view. Default value: "10".
virtual-root
Url which, if specified, will be used as root for all cgit links. It
will also cause cgit to generate 'virtual urls', i.e. urls like
'/cgit/tree/README' as opposed to '?r=cgit&p=tree&path=README'. Default
value: none.
NOTE: cgit has recently learned how to use PATH_INFO to achieve the
same kind of virtual urls, so this option will probably be deprecated.
REPOSITORY SETTINGS
-------------------
repo.clone-url
A list of space-separated urls which can be used to clone this repo.
Default value: none.
repo.defbranch
The name of the default branch for this repository. If no such branch
exists in the repository, the first branch name (when sorted) is used
as default instead. Default value: "master".
repo.desc
The value to show as repository description. Default value: none.
repo.enable-log-filecount
A flag which can be used to disable the global setting
`enable-log-filecount'. Default value: none.
repo.enable-log-linecount
A flag which can be used to disable the global setting
`enable-log-linecount'. Default value: none.
+repo.enable-stats
+ A flag which can be used to disable the global setting
+ `enable-stats'. Default value: none.
+
repo.name
The value to show as repository name. Default value: <repo.url>.
repo.owner
A value used to identify the owner of the repository. Default value:
none.
repo.path
An absolute path to the repository directory. For non-bare repositories
this is the .git-directory. Default value: none.
repo.readme
A path (relative to <repo.path>) which specifies a file to include
verbatim as the "About" page for this repo. Default value: none.
repo.snapshots
A mask of allowed snapshot-formats for this repo, restricted by the
"snapshots" global setting. Default value: <snapshots>.
repo.url
The relative url used to access the repository. This must be the first
setting specified for each repo. Default value: none.
EXAMPLE CGITRC FILE
-------------------
# Enable caching of up to 1000 output entriess
cache-size=1000
# Specify some default clone prefixes
clone-prefix=git://foobar.com ssh://foobar.com/pub/git http://foobar.com/git
# Specify the css url
css=/css/cgit.css
# Show extra links for each repository on the index page
enable-index-links=1
# Show number of affected files per commit on the log pages
enable-log-filecount=1
# Show number of added/removed lines per commit on the log pages
enable-log-linecount=1
# Add a cgit favicon
favicon=/favicon.ico
# Use a custom logo
logo=/img/mylogo.png
# Set the title and heading of the repository index page
root-title=foobar.com git repositories
# Set a subheading for the repository index page
root-desc=tracking the foobar development
# Include some more info about foobar.com on the index page
root-readme=/var/www/htdocs/about.html
# Allow download of tar.gz, tar.bz and zip-files
snapshots=tar.gz tar.bz zip
##
## List of repositories.
## PS: Any repositories listed when repo.group is unset will not be
## displayed under a group heading
## PPS: This list could be kept in a different file (e.g. '/etc/cgitrepos')
## and included like this:
## include=/etc/cgitrepos
##
repo.url=foo
repo.path=/pub/git/foo.git
repo.desc=the master foo repository
repo.owner=fooman@foobar.com
repo.readme=info/web/about.html
repo.url=bar
repo.path=/pub/git/bar.git
repo.desc=the bars for your foo
repo.owner=barman@foobar.com
repo.readme=info/web/about.html
diff --git a/cmd.c b/cmd.c
index 5b3c14c..744bf84 100644
--- a/cmd.c
+++ b/cmd.c
@@ -1,165 +1,175 @@
/* cmd.c: the cgit command dispatcher
*
* Copyright (C) 2008 Lars Hjemli
*
* Licensed under GNU General Public License v2
* (see COPYING for full license text)
*/
#include "cgit.h"
#include "cmd.h"
#include "cache.h"
#include "ui-shared.h"
#include "ui-atom.h"
#include "ui-blob.h"
#include "ui-clone.h"
#include "ui-commit.h"
#include "ui-diff.h"
#include "ui-log.h"
#include "ui-patch.h"
#include "ui-plain.h"
#include "ui-refs.h"
#include "ui-repolist.h"
#include "ui-snapshot.h"
+#include "ui-stats.h"
#include "ui-summary.h"
#include "ui-tag.h"
#include "ui-tree.h"
static void HEAD_fn(struct cgit_context *ctx)
{
cgit_clone_head(ctx);
}
static void atom_fn(struct cgit_context *ctx)
{
cgit_print_atom(ctx->qry.head, ctx->qry.path, 10);
}
static void about_fn(struct cgit_context *ctx)
{
if (ctx->repo)
cgit_print_repo_readme();
else
cgit_print_site_readme();
}
static void blob_fn(struct cgit_context *ctx)
{
cgit_print_blob(ctx->qry.sha1, ctx->qry.path, ctx->qry.head);
}
static void commit_fn(struct cgit_context *ctx)
{
cgit_print_commit(ctx->qry.sha1);
}
static void diff_fn(struct cgit_context *ctx)
{
cgit_print_diff(ctx->qry.sha1, ctx->qry.sha2, ctx->qry.path);
}
static void info_fn(struct cgit_context *ctx)
{
cgit_clone_info(ctx);
}
static void log_fn(struct cgit_context *ctx)
{
cgit_print_log(ctx->qry.sha1, ctx->qry.ofs, ctx->cfg.max_commit_count,
ctx->qry.grep, ctx->qry.search, ctx->qry.path, 1);
}
static void ls_cache_fn(struct cgit_context *ctx)
{
ctx->page.mimetype = "text/plain";
ctx->page.filename = "ls-cache.txt";
cgit_print_http_headers(ctx);
cache_ls(ctx->cfg.cache_root);
}
static void objects_fn(struct cgit_context *ctx)
{
cgit_clone_objects(ctx);
}
static void repolist_fn(struct cgit_context *ctx)
{
cgit_print_repolist();
}
static void patch_fn(struct cgit_context *ctx)
{
cgit_print_patch(ctx->qry.sha1);
}
static void plain_fn(struct cgit_context *ctx)
{
cgit_print_plain(ctx);
}
static void refs_fn(struct cgit_context *ctx)
{
cgit_print_refs();
}
static void snapshot_fn(struct cgit_context *ctx)
{
cgit_print_snapshot(ctx->qry.head, ctx->qry.sha1,
cgit_repobasename(ctx->repo->url), ctx->qry.path,
ctx->repo->snapshots, ctx->qry.nohead);
}
+static void stats_fn(struct cgit_context *ctx)
+{
+ if (ctx->repo->enable_stats)
+ cgit_show_stats(ctx);
+ else
+ cgit_print_error("Stats disabled for this repo");
+}
+
static void summary_fn(struct cgit_context *ctx)
{
cgit_print_summary();
}
static void tag_fn(struct cgit_context *ctx)
{
cgit_print_tag(ctx->qry.sha1);
}
static void tree_fn(struct cgit_context *ctx)
{
cgit_print_tree(ctx->qry.sha1, ctx->qry.path);
}
#define def_cmd(name, want_repo, want_layout) \
{#name, name##_fn, want_repo, want_layout}
struct cgit_cmd *cgit_get_cmd(struct cgit_context *ctx)
{
static struct cgit_cmd cmds[] = {
def_cmd(HEAD, 1, 0),
def_cmd(atom, 1, 0),
def_cmd(about, 0, 1),
def_cmd(blob, 1, 0),
def_cmd(commit, 1, 1),
def_cmd(diff, 1, 1),
def_cmd(info, 1, 0),
def_cmd(log, 1, 1),
def_cmd(ls_cache, 0, 0),
def_cmd(objects, 1, 0),
def_cmd(patch, 1, 0),
def_cmd(plain, 1, 0),
def_cmd(refs, 1, 1),
def_cmd(repolist, 0, 0),
def_cmd(snapshot, 1, 0),
+ def_cmd(stats, 1, 1),
def_cmd(summary, 1, 1),
def_cmd(tag, 1, 1),
def_cmd(tree, 1, 1),
};
int i;
if (ctx->qry.page == NULL) {
if (ctx->repo)
ctx->qry.page = "summary";
else
ctx->qry.page = "repolist";
}
for(i = 0; i < sizeof(cmds)/sizeof(*cmds); i++)
if (!strcmp(ctx->qry.page, cmds[i].name))
return &cmds[i];
return NULL;
}
diff --git a/shared.c b/shared.c
index f5875e4..37333f0 100644
--- a/shared.c
+++ b/shared.c
@@ -1,156 +1,157 @@
/* shared.c: global vars + some callback functions
*
* Copyright (C) 2006 Lars Hjemli
*
* Licensed under GNU General Public License v2
* (see COPYING for full license text)
*/
#include "cgit.h"
struct cgit_repolist cgit_repolist;
struct cgit_context ctx;
int cgit_cmd;
int chk_zero(int result, char *msg)
{
if (result != 0)
die("%s: %s", msg, strerror(errno));
return result;
}
int chk_positive(int result, char *msg)
{
if (result <= 0)
die("%s: %s", msg, strerror(errno));
return result;
}
int chk_non_negative(int result, char *msg)
{
if (result < 0)
die("%s: %s",msg, strerror(errno));
return result;
}
struct cgit_repo *cgit_add_repo(const char *url)
{
struct cgit_repo *ret;
if (++cgit_repolist.count > cgit_repolist.length) {
if (cgit_repolist.length == 0)
cgit_repolist.length = 8;
else
cgit_repolist.length *= 2;
cgit_repolist.repos = xrealloc(cgit_repolist.repos,
cgit_repolist.length *
sizeof(struct cgit_repo));
}
ret = &cgit_repolist.repos[cgit_repolist.count-1];
ret->url = trim_end(url, '/');
ret->name = ret->url;
ret->path = NULL;
ret->desc = "[no description]";
ret->owner = NULL;
ret->group = ctx.cfg.repo_group;
ret->defbranch = "master";
ret->snapshots = ctx.cfg.snapshots;
ret->enable_log_filecount = ctx.cfg.enable_log_filecount;
ret->enable_log_linecount = ctx.cfg.enable_log_linecount;
+ ret->enable_stats = ctx.cfg.enable_stats;
ret->module_link = ctx.cfg.module_link;
ret->readme = NULL;
return ret;
}
struct cgit_repo *cgit_get_repoinfo(const char *url)
{
int i;
struct cgit_repo *repo;
for (i=0; i<cgit_repolist.count; i++) {
repo = &cgit_repolist.repos[i];
if (!strcmp(repo->url, url))
return repo;
}
return NULL;
}
void *cgit_free_commitinfo(struct commitinfo *info)
{
free(info->author);
free(info->author_email);
free(info->committer);
free(info->committer_email);
free(info->subject);
free(info->msg);
free(info->msg_encoding);
free(info);
return NULL;
}
char *trim_end(const char *str, char c)
{
int len;
char *s, *t;
if (str == NULL)
return NULL;
t = (char *)str;
len = strlen(t);
while(len > 0 && t[len - 1] == c)
len--;
if (len == 0)
return NULL;
c = t[len];
t[len] = '\0';
s = xstrdup(t);
t[len] = c;
return s;
}
char *strlpart(char *txt, int maxlen)
{
char *result;
if (!txt)
return txt;
if (strlen(txt) <= maxlen)
return txt;
result = xmalloc(maxlen + 1);
memcpy(result, txt, maxlen - 3);
result[maxlen-1] = result[maxlen-2] = result[maxlen-3] = '.';
result[maxlen] = '\0';
return result;
}
char *strrpart(char *txt, int maxlen)
{
char *result;
if (!txt)
return txt;
if (strlen(txt) <= maxlen)
return txt;
result = xmalloc(maxlen + 1);
memcpy(result + 3, txt + strlen(txt) - maxlen + 4, maxlen - 3);
result[0] = result[1] = result[2] = '.';
return result;
}
void cgit_add_ref(struct reflist *list, struct refinfo *ref)
{
size_t size;
if (list->count >= list->alloc) {
list->alloc += (list->alloc ? list->alloc : 4);
size = list->alloc * sizeof(struct refinfo *);
list->refs = xrealloc(list->refs, size);
}
list->refs[list->count++] = ref;
}
diff --git a/ui-shared.c b/ui-shared.c
index 224e5f3..0e688a0 100644
--- a/ui-shared.c
+++ b/ui-shared.c
@@ -548,167 +548,170 @@ int print_archive_ref(const char *refname, const unsigned char *sha1,
html_link_close();
return 0;
}
void add_hidden_formfields(int incl_head, int incl_search, char *page)
{
char *url;
if (!ctx.cfg.virtual_root) {
url = fmt("%s/%s", ctx.qry.repo, page);
if (ctx.qry.path)
url = fmt("%s/%s", url, ctx.qry.path);
html_hidden("url", url);
}
if (incl_head && ctx.qry.head && ctx.repo->defbranch &&
strcmp(ctx.qry.head, ctx.repo->defbranch))
html_hidden("h", ctx.qry.head);
if (ctx.qry.sha1)
html_hidden("id", ctx.qry.sha1);
if (ctx.qry.sha2)
html_hidden("id2", ctx.qry.sha2);
if (incl_search) {
if (ctx.qry.grep)
html_hidden("qt", ctx.qry.grep);
if (ctx.qry.search)
html_hidden("q", ctx.qry.search);
}
}
char *hc(struct cgit_cmd *cmd, const char *page)
{
return (strcmp(cmd->name, page) ? NULL : "active");
}
void cgit_print_pageheader(struct cgit_context *ctx)
{
struct cgit_cmd *cmd = cgit_get_cmd(ctx);
html("<table id='header'>\n");
html("<tr>\n");
html("<td class='logo' rowspan='2'><a href='");
if (ctx->cfg.logo_link)
html_attr(ctx->cfg.logo_link);
else
html_attr(cgit_rooturl());
html("'><img src='");
html_attr(ctx->cfg.logo);
html("' alt='cgit logo'/></a></td>\n");
html("<td class='main'>");
if (ctx->repo) {
cgit_index_link("index", NULL, NULL, NULL, 0);
html(" : ");
cgit_summary_link(ctx->repo->name, ctx->repo->name, NULL, NULL);
html("</td><td class='form'>");
html("<form method='get' action=''>\n");
add_hidden_formfields(0, 1, ctx->qry.page);
html("<select name='h' onchange='this.form.submit();'>\n");
for_each_branch_ref(print_branch_option, ctx->qry.head);
html("</select> ");
html("<input type='submit' name='' value='switch'/>");
html("</form>");
} else
html_txt(ctx->cfg.root_title);
html("</td></tr>\n");
html("<tr><td class='sub'>");
if (ctx->repo) {
html_txt(ctx->repo->desc);
html("</td><td class='sub right'>");
html_txt(ctx->repo->owner);
} else {
if (ctx->cfg.root_desc)
html_txt(ctx->cfg.root_desc);
else if (ctx->cfg.index_info)
html_include(ctx->cfg.index_info);
}
html("</td></tr></table>\n");
html("<table class='tabs'><tr><td>\n");
if (ctx->repo) {
cgit_summary_link("summary", NULL, hc(cmd, "summary"),
ctx->qry.head);
cgit_refs_link("refs", NULL, hc(cmd, "refs"), ctx->qry.head,
ctx->qry.sha1, NULL);
cgit_log_link("log", NULL, hc(cmd, "log"), ctx->qry.head,
NULL, NULL, 0, NULL, NULL);
cgit_tree_link("tree", NULL, hc(cmd, "tree"), ctx->qry.head,
ctx->qry.sha1, NULL);
cgit_commit_link("commit", NULL, hc(cmd, "commit"),
ctx->qry.head, ctx->qry.sha1);
cgit_diff_link("diff", NULL, hc(cmd, "diff"), ctx->qry.head,
ctx->qry.sha1, ctx->qry.sha2, NULL);
+ if (ctx->repo->enable_stats)
+ reporevlink("stats", "stats", NULL, hc(cmd, "stats"),
+ ctx->qry.head, NULL, NULL);
if (ctx->repo->readme)
reporevlink("about", "about", NULL,
hc(cmd, "about"), ctx->qry.head, NULL,
NULL);
html("</td><td class='form'>");
html("<form class='right' method='get' action='");
if (ctx->cfg.virtual_root)
html_url_path(cgit_fileurl(ctx->qry.repo, "log",
ctx->qry.path, NULL));
html("'>\n");
add_hidden_formfields(1, 0, "log");
html("<select name='qt'>\n");
html_option("grep", "log msg", ctx->qry.grep);
html_option("author", "author", ctx->qry.grep);
html_option("committer", "committer", ctx->qry.grep);
html("</select>\n");
html("<input class='txt' type='text' size='10' name='q' value='");
html_attr(ctx->qry.search);
html("'/>\n");
html("<input type='submit' value='search'/>\n");
html("</form>\n");
} else {
site_link(NULL, "index", NULL, hc(cmd, "repolist"), NULL, 0);
if (ctx->cfg.root_readme)
site_link("about", "about", NULL, hc(cmd, "about"),
NULL, 0);
html("</td><td class='form'>");
html("<form method='get' action='");
html_attr(cgit_rooturl());
html("'>\n");
html("<input type='text' name='q' size='10' value='");
html_attr(ctx->qry.search);
html("'/>\n");
html("<input type='submit' value='search'/>\n");
html("</form>");
}
html("</td></tr></table>\n");
html("<div class='content'>");
}
void cgit_print_filemode(unsigned short mode)
{
if (S_ISDIR(mode))
html("d");
else if (S_ISLNK(mode))
html("l");
else if (S_ISGITLINK(mode))
html("m");
else
html("-");
html_fileperm(mode >> 6);
html_fileperm(mode >> 3);
html_fileperm(mode);
}
void cgit_print_snapshot_links(const char *repo, const char *head,
const char *hex, int snapshots)
{
const struct cgit_snapshot_format* f;
char *filename;
for (f = cgit_snapshot_formats; f->suffix; f++) {
if (!(snapshots & f->bit))
continue;
filename = fmt("%s-%s%s", cgit_repobasename(repo), hex,
f->suffix);
cgit_snapshot_link(filename, NULL, NULL, (char *)head,
(char *)hex, filename);
html("<br/>");
}
}
diff --git a/ui-stats.c b/ui-stats.c
new file mode 100644
index 0000000..9150840
--- a/dev/null
+++ b/ui-stats.c
@@ -0,0 +1,380 @@
+#include "cgit.h"
+#include "html.h"
+#include <string-list.h>
+
+#define MONTHS 6
+
+struct Period {
+ const char code;
+ const char *name;
+ int max_periods;
+ int count;
+
+ /* Convert a tm value to the first day in the period */
+ void (*trunc)(struct tm *tm);
+
+ /* Update tm value to start of next/previous period */
+ void (*dec)(struct tm *tm);
+ void (*inc)(struct tm *tm);
+
+ /* Pretty-print a tm value */
+ char *(*pretty)(struct tm *tm);
+};
+
+struct authorstat {
+ long total;
+ struct string_list list;
+};
+
+#define DAY_SECS (60 * 60 * 24)
+#define WEEK_SECS (DAY_SECS * 7)
+
+static void trunc_week(struct tm *tm)
+{
+ time_t t = timegm(tm);
+ t -= ((tm->tm_wday + 6) % 7) * DAY_SECS;
+ gmtime_r(&t, tm);
+}
+
+static void dec_week(struct tm *tm)
+{
+ time_t t = timegm(tm);
+ t -= WEEK_SECS;
+ gmtime_r(&t, tm);
+}
+
+static void inc_week(struct tm *tm)
+{
+ time_t t = timegm(tm);
+ t += WEEK_SECS;
+ gmtime_r(&t, tm);
+}
+
+static char *pretty_week(struct tm *tm)
+{
+ static char buf[10];
+
+ strftime(buf, sizeof(buf), "W%V %G", tm);
+ return buf;
+}
+
+static void trunc_month(struct tm *tm)
+{
+ tm->tm_mday = 1;
+}
+
+static void dec_month(struct tm *tm)
+{
+ tm->tm_mon--;
+ if (tm->tm_mon < 0) {
+ tm->tm_year--;
+ tm->tm_mon = 11;
+ }
+}
+
+static void inc_month(struct tm *tm)
+{
+ tm->tm_mon++;
+ if (tm->tm_mon > 11) {
+ tm->tm_year++;
+ tm->tm_mon = 0;
+ }
+}
+
+static char *pretty_month(struct tm *tm)
+{
+ static const char *months[] = {
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
+ };
+ return fmt("%s %d", months[tm->tm_mon], tm->tm_year + 1900);
+}
+
+static void trunc_quarter(struct tm *tm)
+{
+ trunc_month(tm);
+ while(tm->tm_mon % 3 != 0)
+ dec_month(tm);
+}
+
+static void dec_quarter(struct tm *tm)
+{
+ dec_month(tm);
+ dec_month(tm);
+ dec_month(tm);
+}
+
+static void inc_quarter(struct tm *tm)
+{
+ inc_month(tm);
+ inc_month(tm);
+ inc_month(tm);
+}
+
+static char *pretty_quarter(struct tm *tm)
+{
+ return fmt("Q%d %d", tm->tm_mon / 3 + 1, tm->tm_year + 1900);
+}
+
+static void trunc_year(struct tm *tm)
+{
+ trunc_month(tm);
+ tm->tm_mon = 0;
+}
+
+static void dec_year(struct tm *tm)
+{
+ tm->tm_year--;
+}
+
+static void inc_year(struct tm *tm)
+{
+ tm->tm_year++;
+}
+
+static char *pretty_year(struct tm *tm)
+{
+ return fmt("%d", tm->tm_year + 1900);
+}
+
+struct Period periods[] = {
+ {'w', "week", 12, 4, trunc_week, dec_week, inc_week, pretty_week},
+ {'m', "month", 12, 4, trunc_month, dec_month, inc_month, pretty_month},
+ {'q', "quarter", 12, 4, trunc_quarter, dec_quarter, inc_quarter, pretty_quarter},
+ {'y', "year", 12, 4, trunc_year, dec_year, inc_year, pretty_year},
+};
+
+static void add_commit(struct string_list *authors, struct commit *commit,
+ struct Period *period)
+{
+ struct commitinfo *info;
+ struct string_list_item *author, *item;
+ struct authorstat *authorstat;
+ struct string_list *items;
+ char *tmp;
+ struct tm *date;
+ time_t t;
+
+ info = cgit_parse_commit(commit);
+ tmp = xstrdup(info->author);
+ author = string_list_insert(tmp, authors);
+ if (!author->util)
+ author->util = xcalloc(1, sizeof(struct authorstat));
+ else
+ free(tmp);
+ authorstat = author->util;
+ items = &authorstat->list;
+ t = info->committer_date;
+ date = gmtime(&t);
+ period->trunc(date);
+ tmp = xstrdup(period->pretty(date));
+ item = string_list_insert(tmp, items);
+ if (item->util)
+ free(tmp);
+ item->util++;
+ authorstat->total++;
+ cgit_free_commitinfo(info);
+}
+
+static int cmp_total_commits(const void *a1, const void *a2)
+{
+ const struct string_list_item *i1 = a1;
+ const struct string_list_item *i2 = a2;
+ const struct authorstat *auth1 = i1->util;
+ const struct authorstat *auth2 = i2->util;
+
+ return auth2->total - auth1->total;
+}
+
+/* Walk the commit DAG and collect number of commits per author per
+ * timeperiod into a nested string_list collection.
+ */
+struct string_list collect_stats(struct cgit_context *ctx,
+ struct Period *period)
+{
+ struct string_list authors;
+ struct rev_info rev;
+ struct commit *commit;
+ const char *argv[] = {NULL, ctx->qry.head, NULL, NULL};
+ time_t now;
+ long i;
+ struct tm *tm;
+ char tmp[11];
+
+ time(&now);
+ tm = gmtime(&now);
+ period->trunc(tm);
+ for (i = 1; i < period->count; i++)
+ period->dec(tm);
+ strftime(tmp, sizeof(tmp), "%Y-%m-%d", tm);
+ argv[2] = xstrdup(fmt("--since=%s", tmp));
+ init_revisions(&rev, NULL);
+ rev.abbrev = DEFAULT_ABBREV;
+ rev.commit_format = CMIT_FMT_DEFAULT;
+ rev.no_merges = 1;
+ rev.verbose_header = 1;
+ rev.show_root_diff = 0;
+ setup_revisions(3, argv, &rev, NULL);
+ prepare_revision_walk(&rev);
+ memset(&authors, 0, sizeof(authors));
+ while ((commit = get_revision(&rev)) != NULL) {
+ add_commit(&authors, commit, period);
+ free(commit->buffer);
+ free_commit_list(commit->parents);
+ }
+ return authors;
+}
+
+void print_combined_authorrow(struct string_list *authors, int from, int to,
+ const char *name, const char *leftclass, const char *centerclass,
+ const char *rightclass, struct Period *period)
+{
+ struct string_list_item *author;
+ struct authorstat *authorstat;
+ struct string_list *items;
+ struct string_list_item *date;
+ time_t now;
+ long i, j, total, subtotal;
+ struct tm *tm;
+ char *tmp;
+
+ time(&now);
+ tm = gmtime(&now);
+ period->trunc(tm);
+ for (i = 1; i < period->count; i++)
+ period->dec(tm);
+
+ total = 0;
+ htmlf("<tr><td class='%s'>%s</td>", leftclass,
+ fmt(name, to - from + 1));
+ for (j = 0; j < period->count; j++) {
+ tmp = period->pretty(tm);
+ period->inc(tm);
+ subtotal = 0;
+ for (i = from; i <= to; i++) {
+ author = &authors->items[i];
+ authorstat = author->util;
+ items = &authorstat->list;
+ date = string_list_lookup(tmp, items);
+ if (date)
+ subtotal += (size_t)date->util;
+ }
+ htmlf("<td class='%s'>%d</td>", centerclass, subtotal);
+ total += subtotal;
+ }
+ htmlf("<td class='%s'>%d</td></tr>", rightclass, total);
+}
+
+void print_authors(struct string_list *authors, int top, struct Period *period)
+{
+ struct string_list_item *author;
+ struct authorstat *authorstat;
+ struct string_list *items;
+ struct string_list_item *date;
+ time_t now;
+ long i, j, total;
+ struct tm *tm;
+ char *tmp;
+
+ time(&now);
+ tm = gmtime(&now);
+ period->trunc(tm);
+ for (i = 1; i < period->count; i++)
+ period->dec(tm);
+
+ html("<table class='stats'><tr><th>Author</th>");
+ for (j = 0; j < period->count; j++) {
+ tmp = period->pretty(tm);
+ htmlf("<th>%s</th>", tmp);
+ period->inc(tm);
+ }
+ html("<th>Total</th></tr>\n");
+
+ if (top <= 0 || top > authors->nr)
+ top = authors->nr;
+
+ for (i = 0; i < top; i++) {
+ author = &authors->items[i];
+ html("<tr><td class='left'>");
+ html_txt(author->string);
+ html("</td>");
+ authorstat = author->util;
+ items = &authorstat->list;
+ total = 0;
+ for (j = 0; j < period->count; j++)
+ period->dec(tm);
+ for (j = 0; j < period->count; j++) {
+ tmp = period->pretty(tm);
+ period->inc(tm);
+ date = string_list_lookup(tmp, items);
+ if (!date)
+ html("<td>0</td>");
+ else {
+ htmlf("<td>%d</td>", date->util);
+ total += (size_t)date->util;
+ }
+ }
+ htmlf("<td class='sum'>%d</td></tr>", total);
+ }
+
+ if (top < authors->nr)
+ print_combined_authorrow(authors, top, authors->nr - 1,
+ "Others (%d)", "left", "", "sum", period);
+
+ print_combined_authorrow(authors, 0, authors->nr - 1, "Total",
+ "total", "sum", "sum", period);
+ html("</table>");
+}
+
+/* Create a sorted string_list with one entry per author. The util-field
+ * for each author is another string_list which is used to calculate the
+ * number of commits per time-interval.
+ */
+void cgit_show_stats(struct cgit_context *ctx)
+{
+ struct string_list authors;
+ struct Period *period;
+ int top, i;
+
+ period = &periods[0];
+ if (ctx->qry.period) {
+ for (i = 0; i < sizeof(periods) / sizeof(periods[0]); i++)
+ if (periods[i].code == ctx->qry.period[0]) {
+ period = &periods[i];
+ break;
+ }
+ }
+ authors = collect_stats(ctx, period);
+ qsort(authors.items, authors.nr, sizeof(struct string_list_item),
+ cmp_total_commits);
+
+ top = ctx->qry.ofs;
+ if (!top)
+ top = 10;
+ htmlf("<h2>Commits per author per %s</h2>", period->name);
+
+ html("<form method='get' action='.' style='float: right; text-align: right;'>");
+ if (strcmp(ctx->qry.head, ctx->repo->defbranch))
+ htmlf("<input type='hidden' name='h' value='%s'/>", ctx->qry.head);
+ html("Period: ");
+ html("<select name='period' onchange='this.form.submit();'>");
+ for (i = 0; i < sizeof(periods) / sizeof(periods[0]); i++)
+ htmlf("<option value='%c'%s>%s</option>",
+ periods[i].code,
+ period == &periods[i] ? " selected" : "",
+ periods[i].name);
+ html("</select><br/><br/>");
+ html("Authors: ");
+ html("");
+ html("<select name='ofs' onchange='this.form.submit();'>");
+ htmlf("<option value='10'%s>10</option>", top == 10 ? " selected" : "");
+ htmlf("<option value='25'%s>25</option>", top == 25 ? " selected" : "");
+ htmlf("<option value='50'%s>50</option>", top == 50 ? " selected" : "");
+ htmlf("<option value='100'%s>100</option>", top == 100 ? " selected" : "");
+ htmlf("<option value='-1'%s>All</option>", top == -1 ? " selected" : "");
+ html("</select>");
+ html("<noscript>&nbsp;&nbsp;<input type='submit' value='Reload'/></noscript>");
+ html("</form>");
+ print_authors(&authors, top, period);
+}
+
diff --git a/ui-stats.h b/ui-stats.h
new file mode 100644
index 0000000..f1d744c
--- a/dev/null
+++ b/ui-stats.h
@@ -0,0 +1,8 @@
+#ifndef UI_STATS_H
+#define UI_STATS_H
+
+#include "cgit.h"
+
+extern void cgit_show_stats(struct cgit_context *ctx);
+
+#endif /* UI_STATS_H */