diff options
context:
space:
mode:
authorBryce Kwon <bryce@brycekwon.com>
committerBryce Kwon <bryce@brycekwon.com>
commit
parent
tree
download
Gate the stats page and add a language breakdown
`max-stats` only bounds the selectable periods now and no longer doubles as the enable switch. The tree walk runs before the history walk on purpose. Releasing commit memory while walking history resets each commit slab index, and a commit graph lookup afterwards would read another commit slot and walk the wrong tree. The stats fixture writes a commit graph so the tests cover that path. The history walk bounds the window in process rather than passing a formatted since date to `setup_revisions`, and parses each commit once.
Diffstat (limited to 'source')
-rw-r--r--source/cgit.c6
-rw-r--r--source/cgit.h2
-rw-r--r--source/cmd.c5
-rw-r--r--source/shared.c1
-rw-r--r--source/ui-shared.c2
-rw-r--r--source/ui-stats.c200
-rw-r--r--source/ui-tree.c2
7 files changed, 197 insertions, 21 deletions
diff --git a/source/cgit.c b/source/cgit.c
index bdfed0b..f83d15d 100644
--- a/source/cgit.c
+++ b/source/cgit.c
@@ -84,6 +84,8 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu
repo->enable_subject_links = atoi(value);
else if (!strcmp(name, "enable-html-serving"))
repo->enable_html_serving = atoi(value);
+ else if (!strcmp(name, "enable-stats"))
+ repo->enable_stats = atoi(value);
else if (!strcmp(name, "branch-sort")) {
if (!strcmp(value, "age"))
repo->branch_sort = 1;
@@ -183,6 +185,8 @@ static void config_cb(const char *name, const char *value)
ctx.cfg.enable_follow_links = atoi(value);
else if (!strcmp(name, "enable-help"))
ctx.cfg.enable_help = atoi(value);
+ else if (!strcmp(name, "enable-stats"))
+ ctx.cfg.enable_stats = atoi(value);
else if (!strcmp(name, "enable-http-clone"))
ctx.cfg.enable_http_clone = atoi(value);
else if (!strcmp(name, "enable-index-links"))
@@ -867,6 +871,8 @@ static void print_repo(FILE *f, struct cgit_repo *repo)
}
if (repo->snapshot_prefix)
fprintf(f, "repo.snapshot-prefix=%s\n", repo->snapshot_prefix);
+ if (repo->enable_stats != ctx.cfg.enable_stats)
+ fprintf(f, "repo.enable-stats=%d\n", repo->enable_stats);
if (repo->max_stats != ctx.cfg.max_stats)
fprintf(f, "repo.max-stats=%s\n",
cgit_find_stats_periodname(repo->max_stats));
diff --git a/source/cgit.h b/source/cgit.h
index 4187bb1..aaf90f8 100644
--- a/source/cgit.h
+++ b/source/cgit.h
@@ -107,6 +107,7 @@ struct cgit_repo {
int enable_remote_branches;
int enable_subject_links;
int enable_html_serving;
+ int enable_stats;
int max_stats;
int branch_sort;
int commit_sort;
@@ -229,6 +230,7 @@ struct cgit_config {
int enable_filter_overrides;
int enable_follow_links;
int enable_help;
+ int enable_stats;
int enable_http_clone;
int enable_index_links;
int enable_index_owner;
diff --git a/source/cmd.c b/source/cmd.c
index a8768eb..84a9d33 100644
--- a/source/cmd.c
+++ b/source/cmd.c
@@ -162,7 +162,10 @@ static void snapshot_fn(void)
static void stats_fn(void)
{
- cgit_show_stats();
+ if (ctx.repo->enable_stats)
+ cgit_show_stats();
+ else
+ cgit_print_error_page(404, "Not found", "Stats are disabled");
}
static void summary_fn(void)
diff --git a/source/shared.c b/source/shared.c
index 8125909..5cd26a9 100644
--- a/source/shared.c
+++ b/source/shared.c
@@ -70,6 +70,7 @@ struct cgit_repo *cgit_add_repo(const char *url)
ret->enable_remote_branches = ctx.cfg.enable_remote_branches;
ret->enable_subject_links = ctx.cfg.enable_subject_links;
ret->enable_html_serving = ctx.cfg.enable_html_serving;
+ ret->enable_stats = ctx.cfg.enable_stats;
ret->max_stats = ctx.cfg.max_stats;
ret->branch_sort = ctx.cfg.branch_sort;
ret->commit_sort = ctx.cfg.commit_sort;
diff --git a/source/ui-shared.c b/source/ui-shared.c
index 2f64c60..477314c 100644
--- a/source/ui-shared.c
+++ b/source/ui-shared.c
@@ -1143,7 +1143,7 @@ void cgit_print_pageheader(void)
hc("diff"), ctx.qry.head,
ctx.qry.oid, ctx.qry.oid2, ctx.qry.vpath);
html("</li>\n");
- if (ctx.repo->max_stats) {
+ if (ctx.repo->enable_stats) {
html("<li>");
cgit_stats_link("stats", "Commit activity", hc("stats"),
ctx.qry.head, ctx.qry.vpath);
diff --git a/source/ui-stats.c b/source/ui-stats.c
index cabbc3c..f6d2637 100644
--- a/source/ui-stats.c
+++ b/source/ui-stats.c
@@ -168,10 +168,9 @@ const char *cgit_find_stats_periodname(int idx)
return "";
}
-static void add_commit(struct string_list *authors, struct commit *commit,
+static void add_commit(struct string_list *authors, struct commitinfo *info,
const struct cgit_period *period)
{
- struct commitinfo *info;
struct string_list_item *author, *item;
struct authorstat *authorstat;
struct string_list *items;
@@ -180,7 +179,6 @@ static void add_commit(struct string_list *authors, struct commit *commit,
time_t t;
uintptr_t *counter;
- info = cgit_parse_commit(commit);
/* A commit can lack an author header, so fall back rather than
* xstrdup(NULL). */
tmp = xstrdup(info->author ? info->author : "(unknown)");
@@ -202,7 +200,6 @@ static void add_commit(struct string_list *authors, struct commit *commit,
(*counter)++;
authorstat->total++;
- cgit_free_commitinfo(info);
}
static int cmp_total_commits(const void *a1, const void *a2)
@@ -215,31 +212,31 @@ static int cmp_total_commits(const void *a1, const void *a2)
return auth2->total - auth1->total;
}
-/* Walk the commit DAG and collect number of commits per author per
- * timeperiod into a nested string_list collection.
+/* 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, NULL, NULL};
- int argc = 3;
- time_t now;
+ const char *argv[] = {NULL, ctx.qry.head, NULL, NULL};
+ int argc = 2;
+ time_t now, since;
long i;
struct tm tm;
- char tmp[11];
time(&now);
gmtime_r(&now, &tm);
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));
+ since = timegm(&tm);
+
if (ctx.qry.path) {
- argv[3] = "--";
- argv[4] = ctx.qry.path;
+ argv[2] = "--";
+ argv[3] = ctx.qry.path;
argc += 2;
}
repo_init_revisions(the_repository, &rev, NULL);
@@ -252,7 +249,12 @@ static struct string_list collect_stats(const struct cgit_period *period)
prepare_revision_walk(&rev);
memset(&authors, 0, sizeof(authors));
while ((commit = get_revision(&rev)) != NULL) {
- add_commit(&authors, commit, period);
+ struct commitinfo *info = cgit_parse_commit(commit);
+
+ if (info->committer_date >= since)
+ add_commit(&authors, info, period);
+
+ cgit_free_commitinfo(info);
release_commit_memory(the_repository->parsed_objects, commit);
commit->parents = NULL;
}
@@ -364,6 +366,150 @@ static void print_authors(struct string_list *authors, int top,
html("</table>");
}
+/* 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("<tr><td class='left'>");
+ html_txt(label);
+ html("</td><td>");
+ html_txt(size.buf);
+ htmlf("</td><td>%.1f%%</td></tr>\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;
+ int i, 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("<h2>Languages</h2>");
+ html("<table class='stats'>");
+ html("<tr><th>Language</th><th>Size</th><th>Share</th></tr>\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("</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.
@@ -371,6 +517,7 @@ static void print_authors(struct string_list *authors, int top,
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";
@@ -384,11 +531,18 @@ void cgit_show_stats(void)
"Unknown statistics type: %c", code[0]);
return;
}
- if (i > ctx.repo->max_stats) {
+ 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);
@@ -398,15 +552,20 @@ void cgit_show_stats(void)
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("<div class='cgit-panel'>");
html("<b>stat options</b>");
html("<form method='get'>");
cgit_add_hidden_formfields(1, 0, "stats");
html("<table><tr><td colspan='2'/></tr>");
- if (ctx.repo->max_stats > 1) {
+ 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("<tr><td class='label'>Period:</td>");
html("<td class='ctrl'><select name='period' data-autosubmit='1'>");
- for (i = 0; i < ctx.repo->max_stats; i++)
+ for (i = 0; i < nperiods; i++)
html_option(fmt("%c", periods[i].code),
periods[i].name, fmt("%c", period->code));
html("</select></td></tr>");
@@ -424,6 +583,7 @@ void cgit_show_stats(void)
html("</td></tr></table>");
html("</form>");
html("</div>");
+
htmlf("<h2>Commits per author per %s", period->name);
if (ctx.qry.path) {
html(" (path '");
@@ -432,6 +592,10 @@ void cgit_show_stats(void)
}
html("</h2>");
print_authors(&authors, top, period);
+
+ print_languages(&lw);
+ string_list_clear(&lw.langs, 0);
+
cgit_print_layout_end();
}
diff --git a/source/ui-tree.c b/source/ui-tree.c
index df7e9e5..c9da688 100644
--- a/source/ui-tree.c
+++ b/source/ui-tree.c
@@ -281,7 +281,7 @@ static void render_ls_item(const struct object_id *oid, const char *pathname,
cgit_log_link("log", NULL, "button", ctx.qry.head,
walk_tree_ctx->curr_rev, fullpath.buf, 0, NULL, NULL,
ctx.qry.showmsg, 0);
- if (ctx.repo->max_stats)
+ if (ctx.repo->enable_stats)
cgit_stats_link("stats", NULL, "button", ctx.qry.head,
fullpath.buf);
if (!S_ISGITLINK(mode))