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] %> &lt;<%= h @info[:email] %>&gt;</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] %> &middot; <%= c[:time].strftime('%Y-%m-%d') %> &middot; <%= 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] %> &middot; <%= 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 &mdash; 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>