From cd9e0e9a53fc921f53d22add7ed422da677c7d19 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Tue, 15 Sep 2026 18:20:18 -1000 Subject: Split trailers out of the commit message --- MANUAL.txt | 38 ++++++++++++++ README.txt | 2 +- assets/cgit.css | 17 +++++++ custom/cgitrc | 19 +++++++ source/cgit.c | 16 +++++- source/cgit.h | 4 ++ source/filter.c | 3 ++ source/filter.h | 2 +- source/shared.c | 2 + source/ui-commit.c | 128 ++++++++++++++++++++++++++++++++++++++++++++++-- tests/t0206-trailers.sh | 91 ++++++++++++++++++++++++++++++++++ 11 files changed, 315 insertions(+), 7 deletions(-) create mode 100755 tests/t0206-trailers.sh diff --git a/MANUAL.txt b/MANUAL.txt index f59a64c..fc91125 100644 --- a/MANUAL.txt +++ b/MANUAL.txt @@ -256,6 +256,16 @@ enable-subject-links:: parent commit as link text when generating links to parent commits in commit view. Default value: "0". See also: "repo.enable-subject-links". +enable-trailers:: + Flag which, when set to "1", makes the commit page split the trailer + block, the Signed-off-by lines and their kin, off the end of the commit + message and show it as a table under the message. The block is found + the way git finds it for "git interpret-trailers": the last paragraph + is taken when every line in it is a trailer, or when it holds a + Signed-off-by and at least a quarter of its lines are trailers. + Default value: "0". See also: "trailer-filter", + "repo.enable-trailers". + enable-tree-group-dirs:: Flag which, when set to "1", makes the tree view list all directories first, sorted, followed by the files. When set to "0", entries appear in @@ -512,6 +522,18 @@ summary-tags:: Specifies the number of tags to display in the repository "summary" view. Default value: "10". +trailer-filter:: + Specifies a command which will be invoked to format the value of a + commit trailer when "enable-trailers" is set. The command will get the + trailer key and an origin page string as its command line arguments and + the value on its STDIN, and the STDOUT from the command will be + included verbatim as the value. Trailers whose value is a name and + email address, such as Signed-off-by, go through the email-filter + instead, and when no trailer-filter is configured the remaining values + go through the commit-filter. A ready filter linking Fixes, Closes and + URL values ships as custom/extensions/link-trailers.lua. Default value: + none. See also: "Filter API". + 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 @@ -600,6 +622,10 @@ repo.enable-subject-links:: A flag which can be used to override the global setting "enable-subject-links". Default value: . +repo.enable-trailers:: + A flag which can be used to override the global setting + "enable-trailers". Default value: . + repo.head-content:: This value will be added verbatim to the html HEAD section of each page displayed for this repo. Default value: none. See also: "head-include". @@ -687,6 +713,11 @@ repo.source-filter:: "enable-filter-overrides" is set. Default value: . See also: "Filter API". +repo.trailer-filter:: + Override the default trailer-filter. Only honoured when + "enable-filter-overrides" is set. Default value: . See + also: "Filter API". + repo.url:: The relative url used to access the repository. This must be the first setting specified for each repo. Default value: none. @@ -805,6 +836,13 @@ source filter:: be filtered is available on standard input and the filtered contents is expected on standard output. +trailer filter:: + This filter is given two parameters: the key of the trailer being + formatted, such as "Fixes", and a string indicating the originating + page. The filter will then receive the trailer value, already HTML + escaped, on standard input and is expected to write to standard output + the formatted value to be included in the page. + All filters are handed the following environment variables: diff --git a/README.txt b/README.txt index bba4a46..55f33de 100644 --- a/README.txt +++ b/README.txt @@ -79,7 +79,7 @@ and each script's header lists the exact install commands for its own. lexer set. * The about-page renderer (`about-render.lua`) needs `lpeg` for markdown and man pages. Plain text needs only Lua. -* `link-commits.lua` needs nothing beyond Lua itself. +* `link-commits.lua` and `link-trailers.lua` need nothing beyond Lua itself. `luaossl` has no Lua 5.5 build, so build cgit against 5.1 to 5.4 if you use the auth or email filters. diff --git a/assets/cgit.css b/assets/cgit.css index c141e3f..462c4ae 100644 --- a/assets/cgit.css +++ b/assets/cgit.css @@ -835,6 +835,23 @@ div#cgit div.commit-msg { font-family: var(--font-mono); } +div#cgit table.commit-trailers { + margin-top: 1em; +} + +div#cgit table.commit-trailers th { + text-align: left; + font-weight: normal; + padding: 0.1em 1em 0.1em 0.1em; + vertical-align: top; + white-space: nowrap; +} + +div#cgit table.commit-trailers td { + padding: 0.1em 1em 0.1em 0.1em; + overflow-wrap: anywhere; +} + div#cgit div.notes-header { font-weight: bold; padding-top: 1.5em; diff --git a/custom/cgitrc b/custom/cgitrc index 6de214f..7ba1e13 100644 --- a/custom/cgitrc +++ b/custom/cgitrc @@ -213,6 +213,11 @@ enable-remote-branches=0 # 1. Default is 0. enable-subject-links=0 +# Split the trailer block, the Signed-off-by lines and their kin, off the end +# of a commit message and show it as a table on the commit page. Values are 0 +# or 1. Default is 0. +enable-trailers=0 + # Allow /plain to serve HTML mimetypes instead of downgrading to text/plain. # Values are 0 or 1. Default is 0. enable-html-serving=0 @@ -274,6 +279,11 @@ enable-filter-overrides=0 # Default is none. #email-filter=lua:/usr/local/lib/cgit/filters/email-gravatar.lua +# Filter command used to format the value of a commit trailer when +# enable-trailers is 1. The shipped filter links Fixes, Closes and URL values. +# Value is a command optionally prefixed with exec or lua. Default is none. +#trailer-filter=lua:/usr/local/lib/cgit/filters/link-trailers.lua + # Filter command used to format plaintext blobs in the tree view. The shipped # filter uses the Scintillua lexers and needs lpeg, and missing either means # plain uncolored text, not an error. Value is a command optionally prefixed @@ -495,6 +505,10 @@ enable-plain-email=1 # global enable-subject-links value. #repo.enable-subject-links=0 +# Per-repo override of enable-trailers. Values are 0 or 1. Default is the +# global enable-trailers value. +#repo.enable-trailers=0 + # Per-repo override of enable-html-serving. Values are 0 or 1. Default is the # global enable-html-serving value. #repo.enable-html-serving=0 @@ -530,3 +544,8 @@ enable-plain-email=1 # enable-filter-overrides is 1. Value is a command. Default is the global # email-filter value. #repo.email-filter=lua:/usr/local/lib/cgit/filters/email-gravatar.lua + +# Per-repo override of the trailer-filter. Only honoured when +# enable-filter-overrides is 1. Value is a command. Default is the global +# trailer-filter value. +#repo.trailer-filter=lua:/usr/local/lib/cgit/filters/link-trailers.lua diff --git a/source/cgit.c b/source/cgit.c index e6e8613..ae91dd2 100644 --- a/source/cgit.c +++ b/source/cgit.c @@ -247,6 +247,8 @@ static void print_repo(FILE *f, struct cgit_repo *repo) cgit_fprintf_filter(repo->source_filter, f, "repo.source-filter="); if (repo->email_filter && repo->email_filter != ctx.cfg.email_filter) cgit_fprintf_filter(repo->email_filter, f, "repo.email-filter="); + if (repo->trailer_filter && repo->trailer_filter != ctx.cfg.trailer_filter) + cgit_fprintf_filter(repo->trailer_filter, f, "repo.trailer-filter="); if (repo->snapshots != ctx.cfg.snapshots) { char *formats = build_snapshot_setting(repo->snapshots); fprintf(f, "repo.snapshots=%s\n", formats ? formats : ""); @@ -262,6 +264,7 @@ static void print_repo(FILE *f, struct cgit_repo *repo) fprintf(f, "repo.logo-link=%s\n", repo->logo_link); fprintf(f, "repo.enable-remote-branches=%d\n", repo->enable_remote_branches); fprintf(f, "repo.enable-subject-links=%d\n", repo->enable_subject_links); + fprintf(f, "repo.enable-trailers=%d\n", repo->enable_trailers); fprintf(f, "repo.enable-html-serving=%d\n", repo->enable_html_serving); if (repo->branch_sort == 1) fprintf(f, "repo.branch-sort=age\n"); @@ -560,6 +563,8 @@ static void apply_config(const char *name, const char *value) ctx.cfg.enable_remote_branches = atoi(value); else if (!strcmp(name, "enable-subject-links")) ctx.cfg.enable_subject_links = atoi(value); + else if (!strcmp(name, "enable-trailers")) + ctx.cfg.enable_trailers = atoi(value); else if (!strcmp(name, "enable-html-serving")) ctx.cfg.enable_html_serving = atoi(value); else if (!strcmp(name, "enable-tree-linenumbers")) @@ -598,6 +603,8 @@ static void apply_config(const char *name, const char *value) ctx.cfg.commit_filter = cgit_new_filter(value, COMMIT); else if (!strcmp(name, "email-filter")) ctx.cfg.email_filter = cgit_new_filter(value, EMAIL); + else if (!strcmp(name, "trailer-filter")) + ctx.cfg.trailer_filter = cgit_new_filter(value, TRAILER); else if (!strcmp(name, "auth-filter")) ctx.cfg.auth_filter = cgit_new_filter(value, AUTH); else if (!strcmp(name, "embedded")) @@ -1165,6 +1172,8 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu repo->enable_remote_branches = atoi(value); else if (!strcmp(name, "enable-subject-links")) repo->enable_subject_links = atoi(value); + else if (!strcmp(name, "enable-trailers")) + repo->enable_trailers = atoi(value); else if (!strcmp(name, "enable-html-serving")) repo->enable_html_serving = atoi(value); else if (!strcmp(name, "branch-sort")) { @@ -1204,7 +1213,8 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu !strcmp(name, "about-filter") || !strcmp(name, "commit-filter") || !strcmp(name, "source-filter") || - !strcmp(name, "email-filter") + !strcmp(name, "email-filter") || + !strcmp(name, "trailer-filter") ) { if (!ctx.cfg.enable_filter_overrides) fprintf(stderr, "[cgit] Ignoring repo %s: enable-filter-overrides is not set\n", name); @@ -1214,8 +1224,10 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu repo->commit_filter = cgit_new_filter(value, COMMIT); else if (!strcmp(name, "source-filter")) repo->source_filter = cgit_new_filter(value, SOURCE); - else + else if (!strcmp(name, "email-filter")) repo->email_filter = cgit_new_filter(value, EMAIL); + else + repo->trailer_filter = cgit_new_filter(value, TRAILER); } else fprintf(stderr, "[cgit] Unknown repo config key: %s\n", name); } diff --git a/source/cgit.h b/source/cgit.h index cc4ac02..d976906 100644 --- a/source/cgit.h +++ b/source/cgit.h @@ -90,6 +90,7 @@ struct cgit_repo { int enable_log_linecount; int enable_remote_branches; int enable_subject_links; + int enable_trailers; int enable_html_serving; int max_stats; int branch_sort; @@ -99,6 +100,7 @@ struct cgit_repo { struct cgit_filter *commit_filter; struct cgit_filter *source_filter; struct cgit_filter *email_filter; + struct cgit_filter *trailer_filter; struct string_list submodules; int hide; int ignore; @@ -228,6 +230,7 @@ struct cgit_config { int enable_relative_dates; int enable_remote_branches; int enable_subject_links; + int enable_trailers; int enable_html_serving; int enable_tree_linenumbers; int enable_tree_group_dirs; @@ -263,6 +266,7 @@ struct cgit_config { struct cgit_filter *commit_filter; struct cgit_filter *source_filter; struct cgit_filter *email_filter; + struct cgit_filter *trailer_filter; struct cgit_filter *auth_filter; }; diff --git a/source/filter.c b/source/filter.c index 2731d58..81c4681 100644 --- a/source/filter.c +++ b/source/filter.c @@ -395,12 +395,14 @@ void cgit_cleanup_filters(void) cleanup_filter(ctx.cfg.commit_filter); cleanup_filter(ctx.cfg.source_filter); cleanup_filter(ctx.cfg.email_filter); + cleanup_filter(ctx.cfg.trailer_filter); cleanup_filter(ctx.cfg.auth_filter); for (i = 0; i < cgit_repolist.count; ++i) { cleanup_filter(cgit_repolist.repos[i].about_filter); cleanup_filter(cgit_repolist.repos[i].commit_filter); cleanup_filter(cgit_repolist.repos[i].source_filter); cleanup_filter(cgit_repolist.repos[i].email_filter); + cleanup_filter(cgit_repolist.repos[i].trailer_filter); } } @@ -438,6 +440,7 @@ struct cgit_filter *cgit_new_filter(const char *cmd, filter_type filtertype) argument_count = 12; break; case EMAIL: + case TRAILER: argument_count = 2; break; case SOURCE: diff --git a/source/filter.h b/source/filter.h index 861578c..fa43416 100644 --- a/source/filter.h +++ b/source/filter.h @@ -13,7 +13,7 @@ #include typedef enum { - ABOUT, COMMIT, SOURCE, EMAIL, AUTH + ABOUT, COMMIT, SOURCE, EMAIL, TRAILER, AUTH } filter_type; struct cgit_filter { diff --git a/source/shared.c b/source/shared.c index beda48b..59712ce 100644 --- a/source/shared.c +++ b/source/shared.c @@ -215,6 +215,7 @@ struct cgit_repo *cgit_add_repo(const char *url) repo->enable_log_linecount = ctx.cfg.enable_log_linecount; repo->enable_remote_branches = ctx.cfg.enable_remote_branches; repo->enable_subject_links = ctx.cfg.enable_subject_links; + repo->enable_trailers = ctx.cfg.enable_trailers; repo->enable_html_serving = ctx.cfg.enable_html_serving; repo->max_stats = ctx.cfg.max_stats; repo->branch_sort = ctx.cfg.branch_sort; @@ -226,6 +227,7 @@ struct cgit_repo *cgit_add_repo(const char *url) repo->commit_filter = ctx.cfg.commit_filter; repo->source_filter = ctx.cfg.source_filter; repo->email_filter = ctx.cfg.email_filter; + repo->trailer_filter = ctx.cfg.trailer_filter; repo->clone_url = ctx.cfg.clone_url; repo->submodules.strdup_strings = 1; repo->hide = repo->ignore = 0; diff --git a/source/ui-commit.c b/source/ui-commit.c index 6055509..b67f5db 100644 --- a/source/ui-commit.c +++ b/source/ui-commit.c @@ -18,6 +18,9 @@ #include "ui-log.h" #include "ui-shared.h" +#include +#include + // The diff below the message is always taken against the first parent alone, // which says little about a merge of many branches, so a commit with this // many parents or more is shown without a diff at all. @@ -76,6 +79,127 @@ static void print_filtered_text(const char *text) cgit_close_filter(ctx.repo->commit_filter); } +/* + * A value shaped like an ident is written the way the author row is, through + * the email filter. Anything else goes through the trailer filter when one is + * set, and otherwise through the commit filter, so the links a site already + * draws in message text keep appearing on trailer lines. + */ +static void print_trailer_value(const char *key, const char *value) +{ + struct ident_split ident; + struct strbuf name = STRBUF_INIT, email = STRBUF_INIT; + + if (!split_ident_line(&ident, value, strlen(value)) && + memchr(ident.mail_begin, '@', ident.mail_end - ident.mail_begin)) { + strbuf_add(&name, ident.name_begin, ident.name_end - ident.name_begin); + strbuf_addch(&email, '<'); + strbuf_add(&email, ident.mail_begin, ident.mail_end - ident.mail_begin); + strbuf_addch(&email, '>'); + cgit_open_filter(ctx.repo->email_filter, email.buf, "commit"); + html_txt(name.buf); + if (ctx.cfg.enable_plain_email) { + html(" "); + html_txt(email.buf); + } + cgit_close_filter(ctx.repo->email_filter); + strbuf_release(&name); + strbuf_release(&email); + } else if (ctx.repo->trailer_filter) { + cgit_open_filter(ctx.repo->trailer_filter, key, "commit"); + html_txt(value); + cgit_close_filter(ctx.repo->trailer_filter); + } else + print_filtered_text(value); +} + +/* + * One row per trailer, except that a run of trailers sharing a key becomes a + * single row listing every value. A line git kept in the block without being + * able to split it, such as a cherry-pick note, gets a row of its own. + */ +static void print_trailers(const char *msg) +{ + struct trailer_iterator iter; + struct strbuf key = STRBUF_INIT; + int open = 0; + + html("\n"); + trailer_iterator_init(&iter, msg); + while (trailer_iterator_advance(&iter)) { + if (!iter.val.len) { + if (open) + html("\n"); + open = 0; + html("\n"); + continue; + } + if (open && !strcasecmp(key.buf, iter.key.buf)) + html("
"); + else { + if (open) + html("\n"); + strbuf_reset(&key); + strbuf_addbuf(&key, &iter.key); + html("\n"); + html("
"); + print_filtered_text(iter.key.buf); + html("
"); + html_txt(key.buf); + html(""); + open = 1; + } + print_trailer_value(key.buf, iter.val.buf); + } + if (open) + html("
\n"); + trailer_iterator_release(&iter); + strbuf_release(&key); +} + +/* + * Git finds the trailer block by looking at the whole message, title included, + * so the subject goes back in front of the body before asking, and the offsets + * that come back are shifted by that prefix. The blank line that separated the + * body from its block is dropped along with the block. + */ +static void print_message(struct commitinfo *info) +{ + struct strbuf full = STRBUF_INIT; + struct process_trailer_options opts = PROCESS_TRAILER_OPTIONS_INIT; + struct trailer_block *block; + LIST_HEAD(items); + size_t prefix, start, end; + char *body = NULL; + + if (ctx.repo->enable_trailers) { + strbuf_addf(&full, "%s\n\n%s", info->subject, info->msg); + prefix = full.len - strlen(info->msg); + opts.no_divider = 1; + block = parse_trailers(&opts, full.buf, &items); + start = trailer_block_start(block); + end = trailer_block_end(block); + free_trailers(&items); + trailer_block_release(block); + if (start >= prefix && start < end) { + size_t len = start - prefix; + + while (len > 0 && info->msg[len - 1] == '\n') + len--; + body = xmemdupz(info->msg, len); + } + } + + html("
"); + print_filtered_text(body ? body : info->msg); + html("
\n"); + if (body) + print_trailers(full.buf); + + free(body); + strbuf_release(&full); +} + void cgit_print_commit(char *hex, const char *prefix) { struct commit *commit; @@ -145,9 +269,7 @@ void cgit_print_commit(char *hex, const char *prefix) print_filtered_text(info->subject); cgit_print_commit_decorations(commit); html("\n"); - html("
"); - print_filtered_text(info->msg); - html("
\n"); + print_message(info); if (notes.len != 0) { html("
Notes
\n"); html("
"); diff --git a/tests/t0206-trailers.sh b/tests/t0206-trailers.sh new file mode 100755 index 0000000..fe96c5a --- /dev/null +++ b/tests/t0206-trailers.sh @@ -0,0 +1,91 @@ +#!/bin/sh + +# The trailer table on the commit page. A commit whose message ends in a +# trailer block is rendered with enable-trailers on, through the exec dump +# filter as both trailer filter and email filter so the arguments each one +# receives show in the page, then with the flag off globally and off for the +# one repository. A commit with no block is checked to get no table. + +test_description='Check the commit trailers table' +CGIT_TEST_NO_CREATE_REPOS=YesPlease +. ./setup.sh + +test_expect_success 'create a repository with a trailer-shaped message' ' + test_create_repo repos/trailers && + ( + cd repos/trailers && + echo content >file && + git add file && + git commit -m "plain commit" && + echo more >>file && + git add file && + git commit -F - <<-\EOF + Fix the frobnicator + + The body keeps its colon: it is not a trailer. + + Fixes: 1234567 ("plain commit") + Signed-off-by: A U Thor + Signed-off-by: C O Mitter + (cherry picked from commit deadbeefcafe1234) + Link: https://example.com/?a=1&b=2 + EOF + ) +' + +test_expect_success 'point cgit at it with trailers on and the dump filters' ' + cat >cgitrc <<-EOF && + virtual-root=/ + cache-size=0 + enable-trailers=1 + enable-filter-overrides=1 + repo.url=trailers + repo.path=$PWD/repos/trailers/.git + repo.trailer-filter=exec:$FILTER_DIRECTORY/dump.sh + repo.email-filter=exec:$FILTER_DIRECTORY/dump.sh + EOF + cgit_url "trailers/commit/" >tmp +' + +test_expect_success 'the body ends where the block begins' ' + grep "
The body keeps its colon: it is not a trailer.
" tmp +' + +test_expect_success 'a value reaches the trailer filter with its key and page' ' + grep "FixesFixes commit 1234567 (.PLAIN COMMIT.)" tmp +' + +test_expect_success 'a run of one key collapses into one row through the email filter' ' + grep "Signed-off-by commit A U THOR <AUTHOR@EXAMPLE.COM>
commit C O MITTER <COMMITTER@EXAMPLE.COM>" tmp +' + +test_expect_success 'a line git could not split gets a row of its own' ' + grep "(cherry picked from commit deadbeefcafe1234)" tmp +' + +test_expect_success 'the escaped value is what the filter sees' ' + grep "LinkLink commit HTTPS://EXAMPLE.COM/?A=1&B=2" tmp +' + +test_expect_success 'a message with no block gets no table' ' + plain=$(git -C repos/trailers rev-parse HEAD~1) && + cgit_url "trailers/commit/&id=$plain" >tmp && + grep "
" tmp && + ! grep "commit-trailers" tmp +' + +test_expect_success 'with the flag off the message stays whole' ' + sed "s/^enable-trailers=1/enable-trailers=0/" cgitrc >cgitrc-off && + CGIT_CONFIG="$PWD/cgitrc-off" QUERY_STRING="url=trailers/commit/" cgit >tmp && + ! grep "commit-trailers" tmp && + grep "Signed-off-by: A U Thor" tmp +' + +test_expect_success 'the repository flag overrides the global one' ' + echo "repo.enable-trailers=0" >>cgitrc && + cgit_url "trailers/commit/" >tmp && + ! grep "commit-trailers" tmp && + grep "Signed-off-by: A U Thor" tmp +' + +test_done -- cgit v2.8.0