commit 2aba3f2d
Create a link for checking out on github
Changed files
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<std::string> split_hosts(const std::string &list) {
+ std::vector<std::string> 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<std::string>("git.static_root", "./static");
cache_root_ = resolve_cache_root(settings().get<std::string>("git.cache_root", ""));
+ github_hosts_ = split_hosts(resolve_github_hosts(
+ settings().get<std::string>("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 <memory>
#include <string>
+#include <vector>
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<std::string> 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<CommitInfo> 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<std::string> &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<std::string> &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 <cstdint>
#include <ctime>
#include <string>
+#include <vector>
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@<host>:owner/repo.git
+ std::string https; // https://<host>/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<std::string> &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:"</"})})();hljs.registerLanguage("dockerfile",e)})();
diff --git a/static/style.css b/static/style.css
index ccc59ab..23933bb 100644
--- a/static/style.css
+++ b/static/style.css
@@ -47,7 +47,9 @@ table { width: 100%; border-collapse: collapse; }
table.tree td, table.log td { padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); }
table.tree .size, table.log .date, table.log .author { color: var(--muted); white-space: nowrap; }
table.tree .size { text-align: right; }
-.commit-more summary { color: var(--muted); font-size: 0.85rem; cursor: pointer; }
+.log-controls { margin: 0 0 0.75rem; }
+.log-toggle { font-size: 0.85rem; color: var(--fg); background: var(--code-bg); border: 1px solid var(--border); border-radius: 4px; padding: 0.25rem 0.6rem; cursor: pointer; }
+.log-toggle[aria-pressed="true"] { border-color: var(--link); color: var(--link); }
.commit-body { margin: 0.5rem 0 0; white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem; }
.dir { font-weight: 600; }
.submodule { color: var(--muted); }
@@ -61,6 +63,24 @@ pre.code {
}
pre.code code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem; }
+.checkout {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 1rem 1.5rem;
+ margin-top: 1rem;
+}
+.checkout h2 { font-size: 1rem; margin: 0 0 0.5rem; }
+.checkout .clone-line { margin: 0.35rem 0; }
+.checkout .clone-label { display: inline-block; width: 3.5rem; color: var(--muted); font-size: 0.85rem; }
+.checkout code {
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.15rem 0.4rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.85rem;
+}
+
.readme {
border: 1px solid var(--border);
border-radius: 6px;
diff --git a/static/terraform.min.js b/static/terraform.min.js
new file mode 100644
index 0000000..4cc54cc
--- /dev/null
+++ b/static/terraform.min.js
@@ -0,0 +1,2 @@
+/*! `terraform` grammar compiled for Highlight.js 11.9.0 */
+(()=>{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 @@
<main><% include content_body() %></main>
<script src="/static/highlight.min.js"></script>
<script src="/static/cmake.min.js"></script>
+<script src="/static/dockerfile.min.js"></script>
+<script src="/static/terraform.min.js"></script>
<script src="/static/marked.min.js"></script>
<script src="/static/app.js"></script>
</body>
@@ -66,6 +68,13 @@
<a href="/repos/<%= repo %>/tree/<%= query %>">files</a> ·
<a href="/repos/<%= repo %>/log/<%= query %>">log</a>
</p>
+<% if (content.clone_https.length() > 0) %>
+<div class="checkout">
+<h2>Checkout</h2>
+<p class="clone-line"><span class="clone-label">HTTPS</span> <code>git clone <%= clone_https %></code></p>
+<p class="clone-line"><span class="clone-label">SSH</span> <code>git clone <%= clone_ssh %></code></p>
+</div>
+<% end %>
<% if (content.readme_name.length() > 0) %>
<article id="readme" class="readme markdown">Loading README…</article>
<div class="markdown-src" data-target="#readme" hidden><%= readme_text %></div>
@@ -182,13 +191,16 @@
<% view log_view uses content::log_view extends master %>
<% template content_body() %>
<h1>log: <%= repo %></h1>
+<% if (content.has_bodies) %>
+<p class="log-controls"><button type="button" class="log-toggle" aria-pressed="false">expand: off</button></p>
+<% end %>
<table class="log">
<% foreach c in commits %><% item %>
<tr>
<td class="commit"><a href="/repos/<%= repo %>/commit/<%= c.oid %>"><%= c.short_oid %></a></td>
<td class="msg"><a href="/repos/<%= repo %>/commit/<%= c.oid %>"><%= c.summary %></a>
<% if (c.body.length() > 0) %>
-<details class="commit-more"><summary>expand</summary><pre class="commit-body"><%= c.body %></pre></details>
+<pre class="commit-body" hidden><%= c.body %></pre>
<% end %>
</td>
<td class="author"><%= c.author_name %></td>