From e4263b952faba40be27f2f5118dc99bb962750d0 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Mon, 24 Aug 2026 16:11:31 -1000 Subject: Resolve submodule links from `.gitmodules` --- source/ui-shared.c | 250 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 244 insertions(+), 6 deletions(-) (limited to 'source/ui-shared.c') diff --git a/source/ui-shared.c b/source/ui-shared.c index 02352bf..317bff2 100644 --- a/source/ui-shared.c +++ b/source/ui-shared.c @@ -14,6 +14,7 @@ #include "cgit.h" #include "html.h" #include "shared.h" +#include "submodule-config.h" #include "ui-shared.h" #include "ui-snapshot.h" @@ -1028,12 +1029,229 @@ void cgit_object_link(struct object *obj) reporevlink(page, name, NULL, NULL, ctx.qry.head, fullrev, NULL); } +/* + * The tree being listed decides which .gitmodules applies, so the revision + * the query names is resolved once and reused for every row. + */ +static const struct object_id *page_treeish(void) +{ + static struct object_id oid; + static int state; + + if (!state) { + const char *rev = ctx.qry.oid ? ctx.qry.oid : ctx.qry.head; + + state = rev && !repo_get_oid(the_repository, rev, &oid) ? 1 : -1; + } + return state > 0 ? &oid : NULL; +} + +static void strip_git_suffix(struct strbuf *sb) +{ + while (sb->len && sb->buf[sb->len - 1] == '/') + strbuf_setlen(sb, sb->len - 1); + strbuf_strip_suffix(sb, ".git"); +} + +/* + * Reduces a submodule url to the path it names on its host, so the tail can + * be compared against the urls this instance serves. A relative url points + * at a sibling of the repository's own clone, which maps onto a sibling of + * its cgit url. + */ +static char *submodule_url_path(const char *url) +{ + struct strbuf sb = STRBUF_INIT; + const char *rest, *colon; + + if (starts_with(url, "./") || starts_with(url, "../")) { + strbuf_addf(&sb, "%s/%s", ctx.repo->url, url); + if (strbuf_normalize_path(&sb)) { + strbuf_release(&sb); + return NULL; + } + } else if (skip_prefix(url, "http://", &rest) || + skip_prefix(url, "https://", &rest) || + skip_prefix(url, "git://", &rest) || + skip_prefix(url, "ssh://", &rest)) { + rest = strchr(rest, '/'); + if (!rest) + return NULL; + strbuf_addstr(&sb, rest); + } else if (skip_prefix(url, "file://", &rest)) { + strbuf_addstr(&sb, rest); + } else if ((colon = strchr(url, ':')) && + !memchr(url, '/', colon - url)) { + // scp syntax, user@host:path + strbuf_addstr(&sb, colon + 1); + } else { + strbuf_addstr(&sb, url); + } + + strip_git_suffix(&sb); + while (sb.len && sb.buf[0] == '/') + strbuf_remove(&sb, 0, 1); + if (!sb.len) { + strbuf_release(&sb); + return NULL; + } + return strbuf_detach(&sb, NULL); +} + +/* + * Finds the served repository whose url matches the most trailing components + * of the submodule url, whatever scheme carried it. + */ +static struct cgit_repo *repo_serving_url(const char *url) +{ + struct cgit_repo *repo, *best = NULL; + char *path = submodule_url_path(url); + size_t plen, rlen, blen = 0; + int i; + + if (!path) + return NULL; + plen = strlen(path); + for (i = 0; i < cgit_repolist.count; i++) { + repo = &cgit_repolist.repos[i]; + if (repo->ignore) + continue; + rlen = strlen(repo->url); + if (!rlen || rlen > plen) + continue; + if (strcmp(path + plen - rlen, repo->url)) + continue; + // Only whole trailing components count, so x/foo cannot + // claim a repository named oo. + if (rlen < plen && path[plen - rlen - 1] != '/') + continue; + if (rlen > blen) { + best = repo; + blen = rlen; + } + } + free(path); + return best; +} + +/* + * Hosts whose repository and commit pages follow a known form, so an ssh + * url can still be offered as a browser link. kernel.org runs cgit, which + * keeps the .git suffix in its own page urls. + */ +static const struct forge { + const char *host; + const char *commit_seg; + int keep_dot_git; +} forges[] = { + { "github.com", "/commit/", 0 }, + { "gitlab.com", "/-/commit/", 0 }, + { "bitbucket.org", "/commits/", 0 }, + { "codeberg.org", "/commit/", 0 }, + { "gitea.com", "/commit/", 0 }, + { "git.sr.ht", "/commit/", 0 }, + { "git.kernel.org", "/commit/?id=", 1 }, +}; + +static const struct forge *forge_for_host(const char *host, size_t len) +{ + size_t i; + + for (i = 0; i < ARRAY_SIZE(forges); i++) + if (strlen(forges[i].host) == len && + !strncasecmp(forges[i].host, host, len)) + return &forges[i]; + return NULL; +} + +/* + * Derives a submodule row's links from its .gitmodules entry. The url is + * matched against this instance's own repositories first, so ssh, file and + * relative urls still land on an internal page when their target is served + * here, and the pinned commit gets a page of its own. A plain web url is + * linked as it is, an ssh url to a known host is rewritten to its web form, + * and anything else is left unlinked with the url as a tooltip, since a + * scheme cgit cannot vouch for has no place in an href. + */ +static void gitmodules_link(const char *path, const char *rev, + char **module, char **commit, + const char **tooltip) +{ + const struct object_id *treeish = page_treeish(); + const struct submodule *sub; + const struct forge *forge; + struct cgit_repo *target; + struct strbuf sb = STRBUF_INIT; + const char *url, *rest, *colon; + + if (!treeish) + return; + sub = submodule_from_path(the_repository, treeish, path); + if (!sub || !sub->url) + return; + url = sub->url; + + target = repo_serving_url(url); + if (target) { + char *query = cgit_fmtalloc("id=%s", rev); + + *module = cgit_pageurl(target->url, "tree", query); + *commit = cgit_pageurl(target->url, "commit", query); + free(query); + return; + } + + if (skip_prefix(url, "http://", &rest) || + skip_prefix(url, "https://", &rest)) { + *module = xstrdup(url); + forge = forge_for_host(rest, strcspn(rest, "/")); + if (forge) { + strbuf_addstr(&sb, url); + while (sb.len && sb.buf[sb.len - 1] == '/') + strbuf_setlen(&sb, sb.len - 1); + if (!forge->keep_dot_git) + strbuf_strip_suffix(&sb, ".git"); + strbuf_addstr(&sb, forge->commit_seg); + strbuf_addstr(&sb, rev); + *commit = strbuf_detach(&sb, NULL); + } + return; + } + + if ((colon = strchr(url, ':')) && !memchr(url, '/', colon - url)) { + const char *host = url; + const char *at = memchr(url, '@', colon - url); + + if (at) + host = at + 1; + forge = forge_for_host(host, colon - host); + if (forge) { + rest = colon + 1; + while (*rest == '/') + rest++; + strbuf_addf(&sb, "https://%.*s/%s", + (int)(colon - host), host, rest); + while (sb.len && sb.buf[sb.len - 1] == '/') + strbuf_setlen(&sb, sb.len - 1); + if (!forge->keep_dot_git) + strbuf_strip_suffix(&sb, ".git"); + *module = xstrdup(sb.buf); + strbuf_addstr(&sb, forge->commit_seg); + strbuf_addstr(&sb, rev); + *commit = strbuf_detach(&sb, NULL); + return; + } + } + + *tooltip = url; +} + void cgit_submodule_link(const char *class, char *path, const char *rev) { struct string_list *list; struct string_list_item *item; - char tail; - const char *name; + char tail, *module = NULL, *commit = NULL; + const char *name, *tooltip = NULL; size_t len; len = 0; @@ -1049,12 +1267,16 @@ void cgit_submodule_link(const char *class, char *path, const char *rev) } } + if (!item && !ctx.repo->module_link && + ctx.repo->enable_gitmodules_links) + gitmodules_link(path, rev, &module, &commit, &tooltip); + // The row shows the entry's own name, like every other row, while // the lookups above want the full path. name = strrchr(path, '/'); name = name ? name + 1 : path; - if (item || ctx.repo->module_link) { + if (item || ctx.repo->module_link || module) { html("util, args, 1); - } else { + } else if (ctx.repo->module_link) { const char *args[] = { name, rev }; emit_module_link(ctx.repo->module_link, args, 2); + } else { + html_attr(module); } html("'>"); html_txt(name); @@ -1073,15 +1297,29 @@ void cgit_submodule_link(const char *class, char *path, const char *rev) html(""); html_txt(name); html(""); } - html(" @ "); html_txtf("%.7s", rev); - html(""); + html(commit ? "" : ""); + free(module); + free(commit); if (tail == '/') path[len - 1] = tail; } -- cgit v2.8.0