diff --git a/README.md b/README.md index b31ee7b..73bb746 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,11 @@ default branch HEAD. created on demand. Overridden by the `GIT_CACHE_ROOT` environment variable; when neither is set it defaults to `$XDG_CACHE_HOME/git_frontend` (or `$HOME/.cache/git_frontend`, falling back to `/tmp/git_frontend`). +- `git.github_enterprise_hosts` — comma- or space-separated hostnames to treat + as GitHub (in addition to `github.com`) when showing clone instructions on a + repository's page, e.g. `github.mycompany.com git.example.org`. Overridden by + the `GITHUB_ENTERPRISE_HOSTS` environment variable. `github.com` is always + recognised, so leave this empty unless you run GitHub Enterprise. - `SHOW_EMAILS` (env) — author/committer email addresses are hidden by default; set `SHOW_EMAILS=1` to show them. diff --git a/config.json b/config.json index 9084482..36ad587 100644 --- a/config.json +++ b/config.json @@ -10,6 +10,7 @@ "git": { "root": "", "static_root": "./static", - "cache_root": "" + "cache_root": "", + "github_enterprise_hosts": "" } } diff --git a/src/application.cpp b/src/application.cpp index c851d9b..ba88b6f 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -35,6 +35,33 @@ std::string resolve_cache_root(const std::string &configured) { return "/tmp/git_frontend"; } +// Splits a comma- or whitespace-separated host list into trimmed, non-empty +// entries. Used for the GitHub Enterprise host allowlist. +std::vector split_hosts(const std::string &list) { + std::vector hosts; + std::string current; + for (const char c : list) { + if (c == ',' || c == ' ' || c == '\t' || c == '\n') { + if (!current.empty()) + hosts.push_back(current); + current.clear(); + } else { + current += c; + } + } + if (!current.empty()) + hosts.push_back(current); + return hosts; +} + +// GitHub Enterprise hosts come from the GITHUB_ENTERPRISE_HOSTS env var (e.g. +// set by the Dockerfile) or the config key "git.github_enterprise_hosts". +std::string resolve_github_hosts(const std::string &configured) { + if (const char *env = std::getenv("GITHUB_ENTERPRISE_HOSTS"); env && *env) + return env; + return configured; +} + // Minimal content-type table for the assets we ship. std::string mime_for(const std::string &path) { const size_t dot = path.find_last_of('.'); @@ -52,6 +79,8 @@ GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner) : cppcms::application(srv), scanner_(scanner) { static_root_ = settings().get("git.static_root", "./static"); cache_root_ = resolve_cache_root(settings().get("git.cache_root", "")); + github_hosts_ = split_hosts(resolve_github_hosts( + settings().get("git.github_enterprise_hosts", ""))); // Root arrives as "" because script_name "/" is stripped; accept both. dispatcher().assign("^/?$", &GitApp::frontpage, this); @@ -145,6 +174,10 @@ void GitApp::repo_home(std::string name) { c.commit_oid = commit_from_request(*repo); c.query = request().get("id").empty() ? "" : "?id=" + c.commit_oid; c.default_branch = repo->default_branch(); + const util::GitHubClone clone = + util::github_clone_urls(repo->remote_url(), github_hosts_); + c.clone_ssh = clone.ssh; + c.clone_https = clone.https; c.readme_name = repo->find_readme(c.commit_oid); if (!c.readme_name.empty()) { bool is_binary = false; @@ -277,6 +310,12 @@ void GitApp::log(std::string name) { c.commit_oid = commit; c.title = "log " + name; c.commits = repo->log(commit, LOG_LIMIT); + for (const CommitInfo &ci : c.commits) { + if (!ci.body.empty()) { + c.has_bodies = true; + break; + } + } render(SKIN, "log_view", c); } catch (const GitError &e) { show_error(404, e.what()); diff --git a/src/application.h b/src/application.h index 3eda0db..48bd586 100644 --- a/src/application.h +++ b/src/application.h @@ -4,6 +4,7 @@ #include #include +#include class RepoScanner; class GitRepo; @@ -47,4 +48,5 @@ private: const RepoScanner *scanner_; std::string static_root_; std::string cache_root_; // where generated tag archives are cached + std::vector github_hosts_; // extra GitHub Enterprise hostnames }; diff --git a/src/content.h b/src/content.h index aa33231..90586c3 100644 --- a/src/content.h +++ b/src/content.h @@ -31,6 +31,8 @@ struct repo_home : public master { std::string default_branch; std::string readme_name; // "" when the repo has no README std::string readme_text; // raw markdown, rendered client-side + std::string clone_ssh; // "" when origin is not a github.com remote + std::string clone_https; // "" when origin is not a github.com remote }; struct tree_view : public master { @@ -68,6 +70,7 @@ struct blame_view : public master { struct log_view : public master { std::string commit_oid; std::vector commits; + bool has_bodies = false; // any commit carries a body beyond its summary }; struct object_view : public master { diff --git a/src/git_repo.cpp b/src/git_repo.cpp index ef9440f..9e720e0 100644 --- a/src/git_repo.cpp +++ b/src/git_repo.cpp @@ -305,6 +305,16 @@ std::string GitRepo::default_branch() const { return result; } +std::string GitRepo::remote_url() const { + git_remote *remote = nullptr; + if (git_remote_lookup(&remote, repo_, "origin") != 0) + return ""; + const char *url = git_remote_url(remote); + const std::string result = url ? url : ""; + git_remote_free(remote); + return result; +} + std::string GitRepo::resolve_commit(const std::string &id) const { git_commit *commit = lookup_commit(repo_, id); const std::string oid = oid_to_string(git_commit_id(commit)); diff --git a/src/git_repo.h b/src/git_repo.h index 0b78345..f364cf0 100644 --- a/src/git_repo.h +++ b/src/git_repo.h @@ -123,6 +123,9 @@ public: // Short name of the default branch (HEAD), e.g. "main". std::string default_branch() const; + // URL of the "origin" remote, or "" when there is no such remote. + std::string remote_url() const; + // Resolve a revision to a full commit oid. Empty id means HEAD. std::string resolve_commit(const std::string &id) const; diff --git a/src/util.cpp b/src/util.cpp index 6052997..aafa9b0 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -120,6 +120,82 @@ std::string image_mime_type(const std::string &path) { return ""; } +namespace { + +// Splits a remote URL into host and path ("owner/repo[.git]"). Returns false +// when the URL has no recognisable host/path split. +bool split_remote(const std::string &url, std::string &host, std::string &path) { + const std::string scheme_sep = "://"; + const size_t scheme = url.find(scheme_sep); + if (scheme != std::string::npos) { + // scheme://[user@]host[:port]/owner/repo + std::string rest = url.substr(scheme + scheme_sep.length()); + const size_t at = rest.find('@'); + if (at != std::string::npos) + rest = rest.substr(at + 1); + const size_t slash = rest.find('/'); + if (slash == std::string::npos) + return false; + host = rest.substr(0, slash); + path = rest.substr(slash + 1); + const size_t port = host.find(':'); + if (port != std::string::npos) + host = host.substr(0, port); + return true; + } + // scp-like: [user@]host:owner/repo + const size_t colon = url.find(':'); + if (colon == std::string::npos) + return false; + std::string host_part = url.substr(0, colon); + path = url.substr(colon + 1); + const size_t at = host_part.find('@'); + host = at == std::string::npos ? host_part : host_part.substr(at + 1); + return true; +} + +bool is_github_host(std::string host, const std::vector &enterprise_hosts) { + std::transform(host.begin(), host.end(), host.begin(), ::tolower); + if (host == "github.com") + return true; + for (const std::string &configured : enterprise_hosts) { + std::string h = configured; + std::transform(h.begin(), h.end(), h.begin(), ::tolower); + if (host == h) + return true; + } + return false; +} + +} // namespace + +GitHubClone github_clone_urls(const std::string &remote_url, + const std::vector &enterprise_hosts) { + GitHubClone result; + std::string host; + std::string path; + if (!split_remote(remote_url, host, path)) + return result; + if (!is_github_host(host, enterprise_hosts)) + return result; + + while (path.length() > 0 && path.front() == '/') + path.erase(path.begin()); + while (path.length() > 0 && path.back() == '/') + path.pop_back(); + const std::string git_suffix = ".git"; + if (path.length() >= git_suffix.length() && + path.compare(path.length() - git_suffix.length(), git_suffix.length(), + git_suffix) == 0) + path = path.substr(0, path.length() - git_suffix.length()); + if (path.empty()) + return result; + + result.ssh = "git@" + host + ":" + path + ".git"; + result.https = "https://" + host + "/" + path + ".git"; + return result; +} + bool show_emails() { static const bool value = [] { const char *flag = std::getenv("SHOW_EMAILS"); diff --git a/src/util.h b/src/util.h index 896f359..4bbcf3e 100644 --- a/src/util.h +++ b/src/util.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace util { @@ -41,4 +42,18 @@ bool is_markdown_path(const std::string &path); // "" when the path is not a recognised image. Case insensitive. std::string image_mime_type(const std::string &path); +// Canonical clone URLs for a GitHub repository. Both strings are "" when the +// remote does not point at a recognised GitHub host. +struct GitHubClone { + std::string ssh; // git@:owner/repo.git + std::string https; // https:///owner/repo.git +}; + +// Parses a git remote URL (scp-like, ssh://, https://, git://) and, when it +// points at github.com or one of the configured GitHub Enterprise hosts in +// `enterprise_hosts`, returns the canonical ssh and https clone URLs for that +// host. Host comparison is case insensitive. +GitHubClone github_clone_urls(const std::string &remote_url, + const std::vector &enterprise_hosts); + } // namespace util diff --git a/static/app.js b/static/app.js index 5cb7ef3..eafa089 100644 --- a/static/app.js +++ b/static/app.js @@ -3,9 +3,15 @@ (function () { "use strict"; - // Map a filename to an explicit highlight.js language. Full-name matches - // (e.g. CMakeLists.txt) take priority over extensions. + // Map a filename to an explicit highlight.js language. Resolution order: + // exact full-name match, then filename patterns, then extension. Anything + // that resolves here overrides highlight.js auto-detection. var FILENAME_LANGUAGES = { "CMakeLists.txt": "cmake" }; + // Patterns matched against the whole filename, in order. + var FILENAME_PATTERNS = [ + { test: /^\..*ignore$/i, language: "plaintext" }, // .gitignore, .dockerignore, ... + { test: /^Dockerfile/i, language: "dockerfile" }, // Dockerfile, Dockerfile.dev, ... + ]; var EXTENSION_LANGUAGES = { cpp: "cpp", hpp: "cpp", @@ -13,11 +19,19 @@ json: "json", yml: "yaml", txt: "plaintext", + man: "plaintext", // no troff/man grammar in highlight.js; pin to plain text + py: "python", + tf: "terraform", + sql: "sql", + patch: "diff", // patch files are unified diffs }; function languageForFilename(filename) { if (!filename) return null; if (FILENAME_LANGUAGES[filename]) return FILENAME_LANGUAGES[filename]; + for (var i = 0; i < FILENAME_PATTERNS.length; i++) { + if (FILENAME_PATTERNS[i].test.test(filename)) return FILENAME_PATTERNS[i].language; + } var dot = filename.lastIndexOf("."); // Extensionless files (LICENSE, README, etc.) are treated as plain text // rather than letting auto-detection guess a language. @@ -49,8 +63,23 @@ }); } + // Single toggle above the log that shows or hides every commit body at once. + function setupLogToggle() { + var btn = document.querySelector(".log-toggle"); + if (!btn) return; + btn.addEventListener("click", function () { + var on = btn.getAttribute("aria-pressed") !== "true"; + btn.setAttribute("aria-pressed", on ? "true" : "false"); + btn.textContent = "expand: " + (on ? "on" : "off"); + document.querySelectorAll(".commit-body").forEach(function (el) { + el.hidden = !on; + }); + }); + } + document.addEventListener("DOMContentLoaded", function () { highlight(); renderMarkdown(); + setupLogToggle(); }); })(); diff --git a/static/dockerfile.min.js b/static/dockerfile.min.js new file mode 100644 index 0000000..88f7b5c --- /dev/null +++ b/static/dockerfile.min.js @@ -0,0 +1,2 @@ +/*! `dockerfile` grammar compiled for Highlight.js 11.9.0 */ +(()=>{var e=(()=>{"use strict";return e=>({name:"Dockerfile",aliases:["docker"],case_insensitive:!0,keywords:["from","maintainer","expose","env","arg","user","onbuild","stopsignal"],contains:[e.HASH_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{beginKeywords:"run cmd entrypoint volume add copy workdir label healthcheck shell",starts:{end:/[^\\]$/,subLanguage:"bash"}}],illegal:"{var e=(()=>{"use strict";return e=>({name:"Terraform",aliases:["tf","hcl"],keywords:{keyword:"resource variable provider output locals module data terraform",literal:"true false null"},contains:[e.COMMENT(/#/,/$/),e.COMMENT(/\/\//,/$/),e.COMMENT(/\/\*/,/\*\//),e.NUMBER_MODE,{className:"string",begin:/"/,end:/"/,contains:[{className:"variable",begin:/\$\{/,end:/\}/}]},{className:"attr",begin:/[a-zA-Z_][\w-]*(?=\s*=)/}]})})();hljs.registerLanguage("terraform",e)})(); diff --git a/templates/skin.tmpl b/templates/skin.tmpl index e21a321..dd426ec 100644 --- a/templates/skin.tmpl +++ b/templates/skin.tmpl @@ -30,6 +30,8 @@
<% include content_body() %>
+ + @@ -66,6 +68,13 @@ files · log

+<% if (content.clone_https.length() > 0) %> +
+

Checkout

+

HTTPS git clone <%= clone_https %>

+

SSH git clone <%= clone_ssh %>

+
+<% end %> <% if (content.readme_name.length() > 0) %>
Loading README…
@@ -182,13 +191,16 @@ <% view log_view uses content::log_view extends master %> <% template content_body() %>

log: <%= repo %>

+<% if (content.has_bodies) %> +

+<% end %> <% foreach c in commits %><% item %>
<%= c.short_oid %> <%= c.summary %> <% if (c.body.length() > 0) %> -
expand
<%= c.body %>
+ <% end %>
<%= c.author_name %>