-- cgit commit-filter that turns git object names and configurable text -- references in a commit message into links, named by the commit-filter or -- repo.commit-filter setting in cgitrc. cgit hands over the message already -- HTML-escaped, so all this does is wrap matches in anchors. Every match is -- resolved in one left-to-right pass, so nothing is ever linked twice. The -- two tables below are the whole configuration, and the filter runs on Lua -- 5.1 through 5.4 and LuaJIT with nothing outside the standard library. -- -- commit-filter=lua:/path/to/link-commits.lua -- Object names are handled apart from the rules below because the length -- bound on them cannot be written as a plain Lua pattern. Recognition is by -- shape, since a commit-filter cannot ask the repository whether a hash is -- real, so any hex run within the bounds is linked whatever mix of digits and -- letters it has and abbreviated and all-digit names are both caught. The -- cost is that a long hex-looking number now and then links to an object that -- does not exist, which cgit renders as a harmless "bad object name" page. local objects = { -- Set false to stop linking bare hashes. enabled = true, -- Git abbreviations run about 7 to 12 characters, and a full name is 40 -- characters for sha1 or 64 for sha256. min_length = 7, max_length = 64, -- Link target, where %s is replaced with the matched hash. The relative -- form is resolved against the current page and works for the common -- virtual-root layout. url = "./?id=%s", } -- Text-reference rules, each one a Lua pattern with a single capture and a -- URL where %s is replaced by that capture, percent-encoded. The whole match -- is what gets shown and the capture is only what goes into the URL. Rules are -- tried in order and the leftmost match on the line wins, so put the more -- specific patterns first, and an empty list leaves only object names linked. -- -- Lua patterns are not regular expressions. There is no alternation and no -- {n,m} repetition, %d is a digit, %a a letter, %w a letter or digit, %x a -- hex digit, and a literal magic character is escaped with %, so a literal -- dash is '%-'. The whole set is in the reference manual at -- https://www.lua.org/manual/5.1/manual.html#5.4.1 -- -- Patterns run against the escaped message, so '&', '<' and '>' reach them as -- '&', '<' and '>'. Match those entity spellings rather than the -- bare character, and keep a pattern from ending part way through one, since -- the matched run is what gets wrapped in the anchor. local rules = { { pattern = "#(%d+)", url = "https://bugs.example.com/?bug=%s" }, -- { pattern = "CVE%-(%d%d%d%d%-%d+)", url = "https://www.cve.org/CVERecord?id=CVE-%s" }, -- { pattern = "!(%d+)", url = "https://gitlab.example.com/group/repo/-/merge_requests/%s" }, -- { pattern = "RFC%s?(%d+)", url = "https://www.rfc-editor.org/rfc/rfc%s" }, } local chunks = {} -- Percent-encode everything but the URL unreserved characters, so a captured -- value cannot break out of the href attribute or out of the URL itself. local function url_encode(s) return (string.gsub(s, "[^%w._~-]", function(c) return string.format("%%%02X", string.byte(c)) end)) end -- Build one anchor from a URL template holding %s and the text to show. The -- replacement is a function so that a '%' in the encoded value is not taken -- for a gsub reference. local function make_link(url_template, capture, display) local encoded = url_encode(capture) local href = string.gsub(url_template, "%%s", function() return encoded end) return "" .. display .. "" end -- Collect every candidate match in the message. Priority records which rule -- found it, and the lower priority wins a tie on the same start position. local function collect(text) local candidates = {} for priority, rule in ipairs(rules) do -- A malformed pattern is an operator error, so skip that -- rule rather than fail the whole page. pcall(function() local init = 1 while init <= #text do local start, stop, capture = string.find(text, rule.pattern, init) if not start then break end if capture == nil then capture = string.sub(text, start, stop) end candidates[#candidates + 1] = { start = start, stop = stop, priority = priority, link = make_link(rule.url, capture, string.sub(text, start, stop)), } -- An empty match still has to advance the -- scan, or it never reaches the end. init = (stop >= start) and stop + 1 or start + 1 end end) end if objects.enabled then local priority = #rules + 1 local init = 1 while init <= #text do local start, stop, run = string.find(text, "%f[%w](%x+)%f[%W]", init) if not start then break end if #run >= objects.min_length and #run <= objects.max_length then candidates[#candidates + 1] = { start = start, stop = stop, priority = priority, link = make_link(objects.url, run, run), } end init = stop + 1 end end return candidates end function filter_open(...) chunks = {} end function filter_write(str) chunks[#chunks + 1] = str end function filter_close() local text = table.concat(chunks) local candidates = collect(text) table.sort(candidates, function(a, b) if a.start ~= b.start then return a.start < b.start end return a.priority < b.priority end) local out = {} local pos = 1 for _, candidate in ipairs(candidates) do -- A candidate reaching back into one already emitted is -- dropped, so no run of text is ever wrapped twice. if candidate.start >= pos then out[#out + 1] = string.sub(text, pos, candidate.start - 1) out[#out + 1] = candidate.link pos = candidate.stop + 1 end end out[#out + 1] = string.sub(text, pos) html(table.concat(out)) return 0 end