gitdir
[root] / gitdir.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'webrick'
require 'erb'
require 'open3'
require 'time'
require 'cgi'
module Config
REPO_DIR = File.expand_path('repos', __dir__)
VIEW_DIR = File.expand_path('views', __dir__)
PUB_DIR = File.expand_path('public', __dir__)
BASE_URL = ENV.fetch('BASE_URL', 'http://localhost:9393')
BIND = ENV.fetch('BIND', '0.0.0.0')
PORT = ENV.fetch('PORT', '9393').to_i
SITE_NAME = ENV.fetch('SITE_NAME', 'git')
end
module Helpers
def h(str)
CGI.escapeHTML(str.to_s)
end
def human_size(bytes)
return '0B' if bytes <= 0
if bytes < 1024
"#{bytes}B"
elsif bytes < 1024 * 1024
"#{(bytes / 1024.0).round(1)}KB"
else
"#{(bytes / (1024.0 * 1024)).round(1)}MB"
end
end
def relative_time(time)
diff = Time.now - time
if diff < 60
"#{diff.to_i}s ago"
elsif diff < 3600
"#{(diff / 60).to_i}m ago"
elsif diff < 86400
"#{(diff / 3600).to_i}h ago"
elsif diff < 2592000
"#{(diff / 86400).to_i}d ago"
else
time.strftime('%Y-%m-%d')
end
end
def path_crumb(repo_name, ref, path)
return '' if path.nil? || path.empty? || path == '.'
parts = path.split('/')
parts.each_with_index.map do |part, i|
sub = parts[0..i].join('/')
"<a href=\"/#{h repo_name}/tree/#{h ref}/#{h sub}\">#{h part}</a>"
end.join(' / ')
end
def repo_nav(repo_name, ref, active)
links = [
['files', "/#{h repo_name}/tree/#{h ref}"],
['log', "/#{h repo_name}/log/#{h ref}"],
['refs', "/#{h repo_name}/refs"]
]
items = links.map do |name, url|
name == active ? "<span>#{name}</span>" : "<a href=\"#{url}\">#{name}</a>"
end
"<div class=\"repo-nav\">#{items.join(' ')} <span class=\"ref-label\">#{h ref}</span></div>"
end
def format_diff(diff)
return '' if diff.nil? || diff.empty?
diff.lines.map do |line|
esc = h(line)
case line
when /^diff /, /^index /, /^\+\+\+ /, /^--- /
%(<span class="diff-head">#{esc}</span>)
when /^@@/
%(<span class="diff-hunk">#{esc}</span>)
when /^\+/
%(<span class="diff-add">#{esc}</span>)
when /^-/
%(<span class="diff-del">#{esc}</span>)
else
esc
end
end.join
end
end
class Repo
attr_reader :path, :name
def initialize(path)
@path = path
@name = File.basename(path, '.git')
end
def self.all
return [] unless File.directory?(Config::REPO_DIR)
Dir.glob(File.join(Config::REPO_DIR, '*.git')).sort.map { |p| new(p) }
end
def self.find(name)
path = File.join(Config::REPO_DIR, "#{name}.git")
return nil unless File.directory?(path)
new(path)
end
def description
desc = File.read(File.join(@path, 'description')).strip
desc.empty? ? 'no description' : desc
rescue Errno::ENOENT
'no description'
end
def empty?
git('rev-parse', 'HEAD')
false
rescue StandardError
true
end
def default_branch
@default_branch ||= git('rev-parse', '--abbrev-ref', 'HEAD').strip
end
def branches
git('branch', '--format=%(refname:short)').lines.map(&:strip).reject(&:empty?)
end
def tags
git('tag', '--sort=-creatordate').lines.map(&:strip).reject(&:empty?)
end
def log(ref = nil, limit: 50)
ref ||= default_branch
raw = git('log', "--format=%H%x00%an%x00%ae%x00%at%x00%s", "-n#{limit}", ref)
raw.lines(chomp: true).reject(&:empty?).map do |line|
hash, author, email, ts, msg = line.split("\x00", 5)
{ hash: hash, author: author, email: email, time: Time.at(ts.to_i), message: msg }
end
end
def tree(ref, path = '')
tree_ish = path.empty? ? ref : git('rev-parse', "#{ref}:#{path}").strip
raw = git('ls-tree', tree_ish)
raw.lines(chomp: true).map do |line|
meta, name = line.split("\t", 2)
mode, type, hash = meta.split(' ', 3)
{ mode: mode, type: type, hash: hash, name: name }
end
end
def blob(ref, path)
git('show', "#{ref}:#{path}")
end
def blob_size(ref, path)
git('cat-file', '-s', "#{ref}:#{path}").strip.to_i
rescue StandardError
0
end
def commit_info(hash)
raw = git('log', '-1', "--format=%H%x00%an%x00%ae%x00%at%x00%s%x00%b", hash)
c_hash, author, email, ts, subject, body = raw.split("\x00", 6)
{
hash: c_hash,
author: author,
email: email,
time: Time.at(ts.to_i),
subject: subject,
body: body.to_s.strip
}
end
def commit_diff(hash)
raw = git('show', '--pretty=format:', '--stat', '-p', '--no-color', hash)
raw.sub(/\A\n+/, '')
rescue StandardError
''
end
def last_modified
raw = git('log', '-1', '--format=%at', default_branch)
Time.at(raw.strip.to_i)
rescue StandardError
Time.now
end
def clone_url
"#{Config::BASE_URL}/#{name}.git"
end
private
def git(*args)
cmd = ['git', '--git-dir', @path] + args.map(&:to_s)
stdout, stderr, status = Open3.capture3(*cmd)
raise "git #{args.first}: #{stderr.strip}" unless status.success?
stdout
end
end
class TemplateScope
include Helpers
def initialize(locals)
locals.each { |k, v| instance_variable_set("@#{k}", v) }
end
def get_binding
binding
end
end
module Templates
def self.render(template, locals = {})
body = partial(template, locals)
locals[:body] = body
partial('layout', locals)
end
def self.partial(name, locals)
path = File.join(Config::VIEW_DIR, "#{name}.erb")
erb = ERB.new(File.read(path), trim_mode: '-')
scope = TemplateScope.new(locals)
erb.result(scope.get_binding)
end
end
class GitServlet < WEBrick::HTTPServlet::AbstractServlet
def do_GET(req, res)
path = CGI.unescape(req.path.sub(%r{/+$}, ''))
case path
when '', '/'
index(res)
when '/style.css'
serve_file(res, File.join(Config::PUB_DIR, 'style.css'), 'text/css')
when %r{^/([^/]+)$}
repo_summary(res, $1)
when %r{^/([^/]+)/log(?:/([^/]+))?$}
repo_log(res, $1, $2)
when %r{^/([^/]+)/tree/([^/]+)$}
repo_tree(res, $1, $2, '')
when %r{^/([^/]+)/tree/([^/]+)/(.+)$}
repo_tree(res, $1, $2, $3)
when %r{^/([^/]+)/blob/([^/]+)/(.+)$}
repo_blob(res, $1, $2, $3)
when %r{^/([^/]+)/raw/([^/]+)/(.+)$}
repo_raw(res, $1, $2, $3)
when %r{^/([^/]+)/commit/([0-9a-f]+)$}
repo_commit(res, $1, $2)
when %r{^/([^/]+)/refs$}
repo_refs(res, $1)
else
not_found(res)
end
rescue StandardError => e
$stderr.puts "#{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
error_response(res, e)
end
private
def render(res, template, locals)
locals[:site_name] = Config::SITE_NAME
res.status = 200
res['Content-Type'] = 'text/html; charset=utf-8'
res.body = Templates.render(template, locals)
end
def index(res)
render(res, 'index', repos: Repo.all, title: Config::SITE_NAME)
end
def repo_summary(res, name)
repo = Repo.find(name) or return not_found(res)
if repo.empty?
return render(res, 'repo_empty', repo: repo, title: repo.name)
end
ref = repo.default_branch
tree = repo.tree(ref)
log = repo.log(ref, limit: 10)
readme = find_readme(repo, ref)
render(res, 'repo', repo: repo, ref: ref, tree: tree, log: log,
readme: readme, title: repo.name)
end
def repo_log(res, name, ref)
repo = Repo.find(name) or return not_found(res)
ref ||= repo.default_branch
log = repo.log(ref, limit: 100)
render(res, 'log', repo: repo, ref: ref, log: log, title: "log - #{repo.name}")
end
def repo_tree(res, name, ref, path)
repo = Repo.find(name) or return not_found(res)
tree = repo.tree(ref, path)
render(res, 'tree', repo: repo, ref: ref, path: path, tree: tree,
title: path.empty? ? repo.name : "#{path} - #{repo.name}")
end
def repo_blob(res, name, ref, path)
repo = Repo.find(name) or return not_found(res)
content = repo.blob(ref, path)
size = repo.blob_size(ref, path)
render(res, 'blob', repo: repo, ref: ref, path: path,
content: content, size: size, title: "#{path} - #{repo.name}")
end
def repo_raw(res, name, ref, path)
repo = Repo.find(name) or return not_found(res)
content = repo.blob(ref, path)
res.status = 200
res['Content-Type'] = 'application/octet-stream'
res['Content-Disposition'] = "inline; filename=\"#{File.basename(path)}\""
res.body = content
end
def repo_commit(res, name, hash)
repo = Repo.find(name) or return not_found(res)
info = repo.commit_info(hash)
diff = repo.commit_diff(hash)
render(res, 'commit', repo: repo, info: info, diff: diff,
title: "#{info[:subject][0..60]} - #{repo.name}")
end
def repo_refs(res, name)
repo = Repo.find(name) or return not_found(res)
render(res, 'refs', repo: repo, branches: repo.branches, tags: repo.tags,
title: "refs - #{repo.name}")
end
def find_readme(repo, ref)
tree = repo.tree(ref)
entry = tree.find { |e| e[:name] =~ /\Areadme/i }
repo.blob(ref, entry[:name]) if entry
rescue StandardError
nil
end
def not_found(res)
res.status = 404
res['Content-Type'] = 'text/html; charset=utf-8'
res.body = Templates.render('404', title: 'Not Found', site_name: Config::SITE_NAME)
rescue StandardError
res.body = '<h1>404 Not Found</h1>'
end
def error_response(res, e)
res.status = 500
res['Content-Type'] = 'text/html; charset=utf-8'
msg = CGI.escapeHTML("#{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
res.body = "<h1>500</h1><pre>#{msg}</pre>"
end
def serve_file(res, path, type)
if File.exist?(path)
res.status = 200
res['Content-Type'] = type
res.body = File.read(path)
else
not_found(res)
end
end
end
server = WEBrick::HTTPServer.new(
Port: Config::PORT,
BindAddress: Config::BIND,
Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
AccessLog: [[$stderr, WEBrick::AccessLog::COMBINED_LOG_FORMAT]]
)
server.mount('/', GitServlet)
trap('INT') { server.shutdown }
trap('TERM') { server.shutdown }
puts "gitdir: serving #{Config::REPO_DIR} on http://#{Config::BIND}:#{Config::PORT}"
server.start