/* ui-stats.c: generate stats view * * Copyright (C) 2006-2014 cgit Development Team * * Licensed under GNU General Public License v2 * (see LICENSE.txt for full license text) */ #define USE_THE_REPOSITORY_VARIABLE #include "cgit.h" #include "ui-stats.h" #include "html.h" #include "ui-shared.h" 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 cgit_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 cgit_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 cgit_fmt("%d", tm->tm_year + 1900); } static const struct cgit_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}, }; /* Given a period code or name, return a period index (1, 2, 3 or 4) * and update the period pointer to the correcsponding struct. * If no matching code is found, return 0. */ int cgit_find_stats_period(const char *expr, const struct cgit_period **period) { size_t i; char code = '\0'; if (!expr) return 0; if (strlen(expr) == 1) code = expr[0]; for (i = 0; i < ARRAY_SIZE(periods); i++) if (periods[i].code == code || !strcmp(periods[i].name, expr)) { if (period) *period = &periods[i]; return i + 1; } return 0; } const char *cgit_find_stats_periodname(int idx) { if (idx > 0 && idx <= (int)ARRAY_SIZE(periods)) return periods[idx - 1].name; else return ""; } static void add_commit(struct string_list *authors, struct commitinfo *info, const struct cgit_period *period) { struct string_list_item *author, *item; struct authorstat *authorstat; struct string_list *items; char *tmp; struct tm date; time_t t; uintptr_t *counter; /* A commit can lack an author header, so fall back rather than * xstrdup(NULL). */ tmp = xstrdup(info->author ? info->author : "(unknown)"); author = string_list_insert(authors, tmp); if (!author->util) author->util = xcalloc(1, sizeof(struct authorstat)); else free(tmp); authorstat = author->util; items = &authorstat->list; t = info->committer_date; // A crafted commit can carry a date outside the range gmtime_r can // represent, which would leave date uninitialized and later index // the month table out of bounds. Drop such a commit from the stats. if (!gmtime_r(&t, &date)) return; period->trunc(&date); tmp = xstrdup(period->pretty(&date)); item = string_list_insert(items, tmp); counter = (uintptr_t *)&item->util; if (*counter) free(tmp); (*counter)++; authorstat->total++; } 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 the sign only, since a long difference truncated to int could // flip and leave the comparator inconsistent. if (auth2->total > auth1->total) return 1; if (auth2->total < auth1->total) return -1; return 0; } /* Walk the commit DAG once for the configured window. One walk, no * diffs, so the cost class matches rev-list and the page cache absorbs * repeat views. Merge commits are skipped. */ static struct string_list collect_stats(const struct cgit_period *period) { struct string_list authors; struct rev_info rev; struct commit *commit; const char *argv[] = {NULL, ctx.qry.head, NULL, NULL}; int argc = 2; time_t now, since; long i; struct tm tm; time(&now); gmtime_r(&now, &tm); period->trunc(&tm); for (i = 1; i < period->count; i++) period->dec(&tm); since = timegm(&tm); if (ctx.qry.path) { argv[2] = "--"; argv[3] = ctx.qry.path; argc += 2; } repo_init_revisions(the_repository, &rev, NULL); rev.abbrev = DEFAULT_ABBREV; rev.commit_format = CMIT_FMT_DEFAULT; rev.max_parents = 1; rev.verbose_header = 1; rev.show_root_diff = 0; setup_revisions(argc, argv, &rev, NULL); // Prune the walk to the displayed window instead of traversing the // whole history and discarding older commits. The in-process check // below still bounds the period edge exactly. rev.max_age = since; prepare_revision_walk(&rev); memset(&authors, 0, sizeof(authors)); while ((commit = get_revision(&rev)) != NULL) { struct commitinfo *info = cgit_parse_commit(commit); if ((time_t)info->committer_date >= since) add_commit(&authors, info, period); cgit_free_commitinfo(info); release_commit_memory(the_repository->parsed_objects, commit); commit->parents = NULL; } return authors; } /* * The column labels for the displayed window, oldest first. Every table below * walks the same period, and each step of that walk converts a struct tm to a * time_t and back, so the labels are worked out once and shared. It also frees * the callers from pretty() handing back a buffer it reuses. */ static struct string_list build_period_labels(const struct cgit_period *period) { struct string_list labels = STRING_LIST_INIT_DUP; struct tm tm; time_t now; int i; time(&now); gmtime_r(&now, &tm); period->trunc(&tm); for (i = 1; i < period->count; i++) period->dec(&tm); for (i = 0; i < period->count; i++) { string_list_append(&labels, period->pretty(&tm)); period->inc(&tm); } return labels; } /* Sum one row across a run of authors. Taking a count rather than an end index * keeps an empty author list from describing a run that wraps. */ static void print_combined_authorrow(struct string_list *authors, size_t from, size_t count, const char *name, const char *leftclass, const char *centerclass, const char *rightclass, const struct string_list *labels) { struct string_list_item *author; struct authorstat *authorstat; struct string_list *items; struct string_list_item *date; size_t i, j; long total, subtotal; total = 0; htmlf("%s", leftclass, cgit_fmt(name, (long)count)); for (j = 0; j < labels->nr; j++) { subtotal = 0; for (i = from; i < from + count; i++) { author = &authors->items[i]; authorstat = author->util; items = &authorstat->list; date = string_list_lookup(items, labels->items[j].string); if (date) subtotal += (uintptr_t)date->util; } htmlf("%ld", centerclass, subtotal); total += subtotal; } htmlf("%ld", rightclass, total); } static void print_authors(struct string_list *authors, int requested_top, const struct string_list *labels) { struct string_list_item *author; struct authorstat *authorstat; struct string_list *items; struct string_list_item *date; size_t i, j, top; long total; html(""); for (j = 0; j < labels->nr; j++) htmlf("", labels->items[j].string); html("\n"); // The author count arrives through ofs, which carries -1 for "all". top = (requested_top <= 0 || (size_t)requested_top > authors->nr) ? authors->nr : (size_t)requested_top; for (i = 0; i < top; i++) { author = &authors->items[i]; html(""); authorstat = author->util; items = &authorstat->list; total = 0; for (j = 0; j < labels->nr; j++) { date = string_list_lookup(items, labels->items[j].string); if (!date) html(""); else { htmlf("", (uintptr_t)date->util); total += (uintptr_t)date->util; } } htmlf("", total); } if (top < authors->nr) print_combined_authorrow(authors, top, authors->nr - top, "Others (%ld)", "left", "", "sum", labels); print_combined_authorrow(authors, 0, authors->nr, "Total", "total", "sum", "sum", labels); html("
Author%sTotal
"); html_txt(author->string); html("0%lu%ld
"); } /* Bytes at HEAD per language, judged by file extension. One recursive * tree read, sizes come from object headers without loading content. */ static const struct { const char *ext; const char *label; } lang_map[] = { {"c", "C"}, {"h", "C"}, {"cpp", "C++"}, {"cc", "C++"}, {"cxx", "C++"}, {"hpp", "C++"}, {"hh", "C++"}, {"js", "JavaScript"}, {"mjs", "JavaScript"}, {"ts", "TypeScript"}, {"tsx", "TypeScript"}, {"py", "Python"}, {"lua", "Lua"}, {"sh", "Shell"}, {"bash", "Shell"}, {"go", "Go"}, {"rs", "Rust"}, {"zig", "Zig"}, {"java", "Java"}, {"kt", "Kotlin"}, {"cs", "C#"}, {"swift", "Swift"}, {"rb", "Ruby"}, {"pl", "Perl"}, {"pm", "Perl"}, {"php", "PHP"}, {"hs", "Haskell"}, {"el", "Lisp"}, {"ml", "OCaml"}, {"css", "CSS"}, {"scss", "CSS"}, {"html", "HTML"}, {"htm", "HTML"}, {"xml", "XML"}, {"md", "Markdown"}, {"rst", "Text"}, {"txt", "Text"}, {"json", "JSON"}, {"yml", "YAML"}, {"yaml", "YAML"}, {"toml", "TOML"}, {"mk", "Make"}, {"tex", "TeX"}, {"sql", "SQL"}, {"vim", "Vimscript"}, }; struct lang_walk_ctx { struct string_list langs; unsigned long total; }; static int lang_walk_cb(const struct object_id *oid, struct strbuf *base, const char *pathname, unsigned mode, void *cbdata) { struct lang_walk_ctx *lw = cbdata; struct string_list_item *item; const char *ext, *label = NULL; unsigned long size; int i; if (S_ISDIR(mode)) return READ_TREE_RECURSIVE; if (!S_ISREG(mode)) return 0; if (odb_read_object_info(the_repository->objects, oid, &size) != OBJ_BLOB || !size) return 0; ext = strrchr(pathname, '.'); if (ext && ext != pathname && ext[1]) { for (i = 0; i < (int)ARRAY_SIZE(lang_map); i++) if (!strcasecmp(ext + 1, lang_map[i].ext)) { label = lang_map[i].label; break; } } else if (!strcmp(pathname, "Makefile")) { label = "Make"; } if (!label) label = "Other"; item = string_list_insert(&lw->langs, label); item->util = (void *)((uintptr_t)item->util + size); lw->total += size; return 0; } static int cmp_lang_bytes(const void *a1, const void *a2) { const struct string_list_item *i1 = a1; const struct string_list_item *i2 = a2; uintptr_t b1 = (uintptr_t)i1->util; uintptr_t b2 = (uintptr_t)i2->util; return b1 < b2 ? 1 : b1 > b2 ? -1 : 0; } static void summarize_tree(struct lang_walk_ctx *lw) { struct pathspec paths = { .nr = 0 }; struct object_id oid; struct commit *commit; if (repo_get_oid(the_repository, ctx.qry.head, &oid)) return; commit = lookup_commit_reference(the_repository, &oid); if (!commit || repo_parse_commit(the_repository, commit)) return; read_tree(the_repository, repo_get_commit_tree(the_repository, commit), &paths, lang_walk_cb, lw); qsort(lw->langs.items, lw->langs.nr, sizeof(struct string_list_item), cmp_lang_bytes); } static void print_language_row(const char *label, unsigned long bytes, unsigned long total) { struct strbuf size = STRBUF_INIT; strbuf_humanise_bytes(&size, bytes); html(""); html_txt(label); html(""); html_txt(size.buf); htmlf("%.1f%%\n", 100.0 * bytes / total); strbuf_release(&size); } /* Bordered like the commits-per-author table. */ static void print_languages(struct lang_walk_ctx *lw) { unsigned long other; size_t i; int shown; if (!lw->total) return; /* Show up to six named rows, everything else folds into an * Other row at the end. */ other = 0; shown = 0; for (i = 0; i < lw->langs.nr; i++) { if (shown < 6 && strcmp(lw->langs.items[i].string, "Other")) { shown++; continue; } other += (uintptr_t)lw->langs.items[i].util; } html("

Languages

"); html(""); html("\n"); shown = 0; for (i = 0; i < lw->langs.nr && shown < 6; i++) { if (!strcmp(lw->langs.items[i].string, "Other")) continue; print_language_row(lw->langs.items[i].string, (uintptr_t)lw->langs.items[i].util, lw->total); shown++; } if (other) print_language_row("Other", other, lw->total); html("
LanguageSizeShare
"); } /* 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(void) { struct string_list authors; struct lang_walk_ctx lw; const struct cgit_period *period; int top, i; const char *code = "w"; if (ctx.qry.period) code = ctx.qry.period; i = cgit_find_stats_period(code, &period); if (!i) { cgit_print_error_page(404, "Not found", "Unknown statistics type: %c", code[0]); return; } if (ctx.repo->max_stats && i > ctx.repo->max_stats) { cgit_print_error_page(400, "Bad request", "Statistics type disabled: %s", period->name); return; } /* Walk the tree before the history walk. Releasing commit memory * during that walk resets each commit's slab index, and a later * lookup through the commit graph would then read another * commit's slot and walk the wrong tree. */ memset(&lw, 0, sizeof(lw)); summarize_tree(&lw); authors = collect_stats(period); qsort(authors.items, authors.nr, sizeof(struct string_list_item), cmp_total_commits); top = ctx.qry.ofs; if (!top) top = 10; cgit_print_layout_start(); /* The options panel floats right of the page top, the same spot * the diff controls occupy on the diff pages. */ html("
"); html("stat options"); html("
"); cgit_add_hidden_formfields(1, 0, "stats"); html(""); if (!ctx.repo->max_stats || ctx.repo->max_stats > 1) { int nperiods = ctx.repo->max_stats ? ctx.repo->max_stats : (int)ARRAY_SIZE(periods); html(""); html(""); } html(""); html(""); html("
Period:
Authors:
"); html(""); html("
"); html("
"); html("
"); htmlf("

Commits per author per %s", period->name); if (ctx.qry.path) { html(" (path '"); html_txt(ctx.qry.path); html("')"); } html("

"); { struct string_list labels = build_period_labels(period); print_authors(&authors, top, &labels); string_list_clear(&labels, 0); } print_languages(&lw); string_list_clear(&lw.langs, 0); cgit_print_layout_end(); }