gitdir
init
62144725efdcfff556fa85833a1eae8020e638b3
SM <seb.michalk@gmail.com>
2026-04-27 13:26:34 +0000
.gitignore | 3 + gitdir.rb | 384 +++++++++++++++++++++++++++++++++++++++++++++++++++ public/style.css | 84 +++++++++++ readme.txt | 34 +++++ views/404.erb | 2 + views/blob.erb | 10 ++ views/commit.erb | 13 ++ views/index.erb | 17 +++ views/layout.erb | 15 ++ views/log.erb | 9 ++ views/refs.erb | 17 +++ views/repo.erb | 19 +++ views/repo_empty.erb | 3 + views/tree.erb | 22 +++ 14 files changed, 632 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1033bf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +repos/ +*.swp +*~ diff --git a/gitdir.rb b/gitdir.rb new file mode 100644 index 0000000..6f79504 --- /dev/null +++ b/gitdir.rb @@ -0,0 +1,384 @@ +#!/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 diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..cf31d88 --- /dev/null +++ b/public/style.css @@ -0,0 +1,84 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { font-size: 15px; } + +body { + font-family: "Courier New", Courier, monospace; + line-height: 1.5; + color: #111; + background: #fefefe; + max-width: 960px; + margin: 0 auto; + padding: 20px 16px; +} + +a { color: #00c; text-decoration: none; } +a:hover { text-decoration: underline; } + +nav { + margin-bottom: 1rem; + padding-bottom: 0.4rem; + border-bottom: 2px solid #111; +} +nav a { font-weight: bold; color: #111; } + +h1 { font-size: 1.4rem; margin: 0 0 0.5rem; } +h3 { font-size: 1.1rem; margin: 1rem 0 0.4rem; } + +table { width: 100%; border-collapse: collapse; margin: 0.3rem 0; } +th, td { text-align: left; padding: 2px 8px; white-space: nowrap; } +th { border-bottom: 1px solid #aaa; } +tr:hover { background: #f5f5f5; } + +pre { + background: #f4f4f4; + padding: 12px; + overflow-x: auto; + font-size: 0.85rem; + line-height: 1.4; + border: 1px solid #ddd; + margin: 0.3rem 0; +} + +hr { border: none; border-top: 1px solid #ddd; margin: 0.8rem 0; } + +.desc { color: #555; } +.meta { color: #666; font-size: 0.9em; } +.mode { color: #555; font-size: 0.85em; } +.hash { font-size: 0.85em; } +.ref-label { color: #666; font-size: 0.9em; } + +.commit-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 4px 0; + border-bottom: 1px solid #eee; +} + +.repo-nav { + margin: 0.4rem 0; + padding: 4px 0; + border-bottom: 1px solid #ddd; +} +.repo-nav a, .repo-nav span { margin-right: 0.8em; } + +.clone-url { margin: 0.3rem 0; font-size: 0.9em; color: #555; } +.clone-url code { background: #eee; padding: 1px 6px; } + +.path { font-size: 0.9em; color: #666; margin: 2px 0; } +.readme { margin: 1rem 0; } +.commit-info p { margin: 0.2rem 0; } + +.diff { margin-top: 0.5rem; } +.diff pre { font-size: 0.8rem; } +.diff-add { color: #060; } +.diff-del { color: #a00; } +.diff-head { color: #00c; font-weight: bold; } +.diff-hunk { color: #808; } + +.ref-row { padding: 2px 0; } diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..f26792e --- /dev/null +++ b/readme.txt @@ -0,0 +1,34 @@ +gitdir - a minimal git repository viewer +======================================== + +dependencies: ruby 3.3+, git + +usage: + ruby gitdir.rb + + environment variables: + BIND bind address (default: 0.0.0.0) + PORT port (default: 9393) + BASE_URL external url (default: http://localhost:9393) + SITE_NAME site title (default: git) + +adding repos: + git clone --bare /path/to/project repos/project.git + + repos must end in .git to be detected. + + edit repos/project.git/description to set the description + shown on the index page. + +routes: + / repo listing + /:repo summary (readme, recent commits) + /:repo/tree/:ref file listing + /:repo/tree/:ref/:path subdirectory listing + /:repo/blob/:ref/:path view file + /:repo/raw/:ref/:path raw download + /:repo/log/:ref commit history + /:repo/commit/:hash commit detail with diff + /:repo/refs branches and tags + +no external gems. stdlib only. diff --git a/views/404.erb b/views/404.erb new file mode 100644 index 0000000..583eb2a --- /dev/null +++ b/views/404.erb @@ -0,0 +1,2 @@ +<h1>404</h1> +<p>Not found.</p> diff --git a/views/blob.erb b/views/blob.erb new file mode 100644 index 0000000..be9ec1e --- /dev/null +++ b/views/blob.erb @@ -0,0 +1,10 @@ +<h1><a href="/<%= h @repo.name %>"><%= h @repo.name %></a></h1> +<p class="path"> + <a href="/<%= h @repo.name %>/tree/<%= h @ref %>">[root]</a><%= "/ #{path_crumb(@repo.name, @ref, File.dirname(@path))}" if File.dirname(@path) != '.' %> + / <%= h File.basename(@path) %> +</p> +<p class="meta"><%= human_size(@size) %></p> +<div class="repo-nav"> + <a href="/<%= h @repo.name %>/raw/<%= h @ref %>/<%= h @path %>">raw</a> +</div> +<pre><%= h @content %></pre> diff --git a/views/commit.erb b/views/commit.erb new file mode 100644 index 0000000..787e1f7 --- /dev/null +++ b/views/commit.erb @@ -0,0 +1,13 @@ +<h1><a href="/<%= h @repo.name %>"><%= h @repo.name %></a></h1> +<h3><%= h @info[:subject] %></h3> +<div class="commit-info"> + <p><strong><%= h @info[:hash] %></strong></p> + <p><%= h @info[:author] %> <<%= h @info[:email] %>></p> + <p><%= @info[:time].strftime('%Y-%m-%d %H:%M:%S %z') %></p> + <%- if @info[:body] && !@info[:body].empty? -%> + <pre><%= h @info[:body] %></pre> + <%- end -%> +</div> +<div class="diff"> +<pre><%= format_diff(@diff) %></pre> +</div> diff --git a/views/index.erb b/views/index.erb new file mode 100644 index 0000000..5a21970 --- /dev/null +++ b/views/index.erb @@ -0,0 +1,17 @@ +<h1><%= h @site_name %></h1> +<% if @repos.empty? %> +<p>No repositories found. Place bare git repos (<code>*.git</code>) in the repos directory.</p> +<% else %> +<table> +<thead><tr><th>Name</th><th>Description</th><th>Updated</th></tr></thead> +<tbody> +<%- @repos.each do |repo| -%> +<tr> + <td><a href="/<%= h repo.name %>"><%= h repo.name %></a></td> + <td><%= h repo.description %></td> + <td><%= repo.last_modified.strftime('%Y-%m-%d') %></td> +</tr> +<%- end -%> +</tbody> +</table> +<% end %> diff --git a/views/layout.erb b/views/layout.erb new file mode 100644 index 0000000..6a4ae21 --- /dev/null +++ b/views/layout.erb @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title><%= h @title %></title> + <link rel="stylesheet" href="/style.css"> +</head> +<body> +<nav><a href="/"><%= h @site_name %></a></nav> +<main> +<%= @body %> +</main> +</body> +</html> diff --git a/views/log.erb b/views/log.erb new file mode 100644 index 0000000..8b2d2e3 --- /dev/null +++ b/views/log.erb @@ -0,0 +1,9 @@ +<h1><a href="/<%= h @repo.name %>"><%= h @repo.name %></a></h1> +<%= repo_nav(@repo.name, @ref, 'log') %> + +<%- @log.each do |c| -%> +<div class="commit-row"> + <a href="/<%= h @repo.name %>/commit/<%= h c[:hash] %>"><%= h c[:message] %></a> + <span class="meta"><%= h c[:author] %> · <%= c[:time].strftime('%Y-%m-%d') %> · <%= h c[:hash][0..7] %></span> +</div> +<%- end -%> diff --git a/views/refs.erb b/views/refs.erb new file mode 100644 index 0000000..b97ee88 --- /dev/null +++ b/views/refs.erb @@ -0,0 +1,17 @@ +<h1><a href="/<%= h @repo.name %>"><%= h @repo.name %></a></h1> +<%= repo_nav(@repo.name, @repo.default_branch, 'refs') %> + +<h3>branches</h3> +<%- @branches.each do |b| -%> +<div class="ref-row"> + <a href="/<%= h @repo.name %>/tree/<%= h b %>"><%= h b %></a> + <%- if b == @repo.default_branch -%><span class="meta">(default)</span><%- end -%> +</div> +<%- end -%> + +<%- unless @tags.empty? -%> +<h3>tags</h3> +<%- @tags.each do |t| -%> +<div class="ref-row"><a href="/<%= h @repo.name %>/tree/<%= h t %>"><%= h t %></a></div> +<%- end -%> +<%- end -%> diff --git a/views/repo.erb b/views/repo.erb new file mode 100644 index 0000000..2092e62 --- /dev/null +++ b/views/repo.erb @@ -0,0 +1,19 @@ +<h1><a href="/<%= h @repo.name %>"><%= h @repo.name %></a></h1> +<p class="desc"><%= h @repo.description %></p> +<p class="clone-url">clone: <code>git clone <%= h @repo.clone_url %></code></p> +<%= repo_nav(@repo.name, @ref, 'files') %> + +<%- if @readme -%> +<div class="readme"> +<h3>README</h3> +<pre><%= h @readme %></pre> +</div> +<%- end -%> + +<h3>recent commits</h3> +<%- @log.each do |c| -%> +<div class="commit-row"> + <a href="/<%= h @repo.name %>/commit/<%= h c[:hash] %>"><%= h c[:message] %></a> + <span class="meta"><%= h c[:author] %> · <%= c[:time].strftime('%Y-%m-%d') %></span> +</div> +<%- end -%> diff --git a/views/repo_empty.erb b/views/repo_empty.erb new file mode 100644 index 0000000..ee9d8b0 --- /dev/null +++ b/views/repo_empty.erb @@ -0,0 +1,3 @@ +<h1><%= h @repo.name %></h1> +<p class="desc"><%= h @repo.description %></p> +<p>Empty repository — no commits yet.</p> diff --git a/views/tree.erb b/views/tree.erb new file mode 100644 index 0000000..f662103 --- /dev/null +++ b/views/tree.erb @@ -0,0 +1,22 @@ +<h1><a href="/<%= h @repo.name %>"><%= h @repo.name %></a></h1> +<p class="path"> + <a href="/<%= h @repo.name %>/tree/<%= h @ref %>">[root]</a><%= "/ #{path_crumb(@repo.name, @ref, @path)}" unless @path.empty? %> +</p> +<%= repo_nav(@repo.name, @ref, 'files') %> + +<table> +<%- @tree.each do |entry| -%> +<%- full = @path.empty? ? entry[:name] : "#{@path}/#{entry[:name]}" -%> +<tr> + <td class="mode"><%= h entry[:mode] %></td> + <td> + <%- if entry[:type] == 'tree' -%> + <a href="/<%= h @repo.name %>/tree/<%= h @ref %>/<%= h full %>"><%= h entry[:name] %>/</a> + <%- else -%> + <a href="/<%= h @repo.name %>/blob/<%= h @ref %>/<%= h full %>"><%= h entry[:name] %></a> + <%- end -%> + </td> + <td class="hash"><%= h entry[:hash][0..7] %></td> +</tr> +<%- end -%> +</table>