Commit de14376

mo khan <mo@mokhan.ca>
2025-12-11 23:27:45
feat: add subcommand to generate and run server tag: v0.2.0
1 parent ff11ed9
exe/gitem
@@ -2,20 +2,4 @@
 # frozen_string_literal: true
 
 require "gitem"
-
-if ARGV.empty?
-  puts "Usage: ruby #{$0} <path-to-git-repo> [output-directory]"
-  puts "Default output: <repo>/.git/srv/"
-  puts "Example: ruby #{$0} ."
-  exit 1
-end
-
-repo_path = ARGV[0]
-output_dir = ARGV[1]
-
-unless File.exist?(File.join(repo_path, '.git')) || File.exist?(File.join(repo_path, 'HEAD'))
-  puts "Error: #{repo_path} is not a valid git repository"
-  exit 1
-end
-
-Gitem::GitToJson.new(repo_path, output_dir).export!
+Gitem::CLI.new(ARGV).run
lib/gitem/cli.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+module Gitem
+  class CLI
+    def initialize(argv)
+      @argv = argv.dup
+      @options = { output: nil, port: 8000, generate: true, open: false }
+    end
+
+    def run
+      command = @argv.shift || "serve"
+      case command
+      when "generate", "g" then run_generate
+      when "serve", "s" then run_serve
+      when "help", "-h", "--help" then print_help
+      when "version", "-v", "--version" then puts "gitem #{VERSION}"
+      else
+        warn "Unknown command: #{command}"
+        print_help
+        exit 1
+      end
+    rescue Rugged::Error, Rugged::RepositoryError => e
+      warn "Git error: #{e.message}"
+      exit 1
+    rescue Error => e
+      warn "Error: #{e.message}"
+      exit 1
+    end
+
+    private
+
+    def run_generate
+      parse_generate_options!
+      validate_repo!
+      Generator.new(@options[:repo_path], @options[:output]).export!
+    end
+
+    def run_serve
+      parse_serve_options!
+      validate_repo!
+      generator = Generator.new(@options[:repo_path], @options[:output])
+      generator.export! if @options[:generate]
+      server = Server.new(generator.output_dir, @options[:port])
+      open_browser(server.url) if @options[:open]
+      server.start
+    end
+
+    def parse_generate_options!
+      OptionParser.new do |opts|
+        opts.banner = "Usage: gitem generate [REPO_PATH] [options]"
+        opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v }
+        opts.on("-h", "--help", "Show help") { puts opts; exit }
+      end.parse!(@argv)
+      @options[:repo_path] = @argv.shift || "."
+    end
+
+    def parse_serve_options!
+      OptionParser.new do |opts|
+        opts.banner = "Usage: gitem serve [REPO_PATH] [options]"
+        opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v }
+        opts.on("-p", "--port PORT", Integer, "Port (default: 8000)") { |v| @options[:port] = v }
+        opts.on("--[no-]generate", "Generate before serving") { |v| @options[:generate] = v }
+        opts.on("--open", "Open browser") { @options[:open] = true }
+        opts.on("-h", "--help", "Show help") { puts opts; exit }
+      end.parse!(@argv)
+      @options[:repo_path] = @argv.shift || "."
+    end
+
+    def validate_repo!
+      path = @options[:repo_path]
+      return if File.exist?(File.join(path, ".git")) || File.exist?(File.join(path, "HEAD"))
+      raise Error, "'#{path}' is not a valid git repository"
+    end
+
+    def open_browser(url)
+      cmd = case RbConfig::CONFIG["host_os"]
+            when /darwin/i then "open"
+            when /linux/i then "xdg-open"
+            when /mswin|mingw|cygwin/i then "start"
+            end
+      system("#{cmd} #{url} > /dev/null 2>&1 &") if cmd
+    end
+
+    def print_help
+      puts <<~HELP
+        Usage: gitem <command> [options]
+
+        Commands:
+          serve, s       Generate and serve (default)
+          generate, g    Generate JSON files only
+          help           Show help
+          version        Show version
+
+        Run 'gitem <command> --help' for options.
+      HELP
+    end
+  end
+end
lib/gitem/generator.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+
+module Gitem
+  class Generator
+    TEMPLATE_PATH = File.expand_path("index.html", __dir__)
+    attr_reader :output_dir
+
+    def initialize(repo_path, output_dir = nil)
+      @repo = Rugged::Repository.new(repo_path)
+      @output_dir = output_dir || File.join(@repo.path, "srv")
+      @processed_trees = Set.new
+      @processed_blobs = Set.new
+    end
+
+    def export!
+      setup_directories
+      export_branches
+      export_tags
+      export_commits
+      export_repo_info
+      copy_template
+      puts "āœ“ Generated: #{@output_dir}"
+    end
+
+    private
+
+    def setup_directories
+      %w[commits trees blobs refs/heads refs/tags].each do |dir|
+        FileUtils.mkdir_p(File.join(@output_dir, dir))
+      end
+    end
+
+    def copy_template
+      FileUtils.cp(TEMPLATE_PATH, File.join(@output_dir, "index.html"))
+    end
+
+    def export_repo_info
+      branch = @repo.branches[default_branch_name]
+      readme_content, readme_name = extract_readme(branch)
+      write_json("repo.json", {
+        name: repo_name, default_branch: default_branch_name,
+        branches_count: local_branches.size, tags_count: @repo.tags.count,
+        readme: readme_content, readme_name: readme_name, generated_at: Time.now.iso8601
+      })
+    end
+
+    def repo_name
+      File.basename(@repo.workdir || @repo.path.chomp("/.git/").chomp(".git"))
+    end
+
+    def default_branch_name
+      @default_branch_name ||= %w[main master].find { |n| @repo.branches[n] } || local_branches.first&.name || "main"
+    end
+
+    def local_branches
+      @local_branches ||= @repo.branches.select { |b| b.name && !b.name.include?("/") }
+    end
+
+    def extract_readme(branch)
+      return [nil, nil] unless branch&.target
+      tree = branch.target.tree
+      %w[README.md README.markdown readme.md README.txt README].each do |name|
+        entry = tree.each.find { |e| e[:name].casecmp?(name) }
+        next unless entry&.dig(:type) == :blob
+        blob = @repo.lookup(entry[:oid])
+        next if blob.binary?
+        return [blob.content.encode("UTF-8", invalid: :replace, undef: :replace), entry[:name]]
+      end
+      [nil, nil]
+    end
+
+    def export_branches
+      branches = local_branches.filter_map do |branch|
+        target = branch.target
+        next unless target
+        { name: branch.name, sha: target.oid, is_head: branch.head?,
+          committed_at: target.committer[:time].iso8601, author: target.author[:name],
+          message: target.message.lines.first&.strip || "" }
+      end.sort_by { |b| b[:is_head] ? 0 : 1 }
+      write_json("branches.json", branches)
+      branches.each { |b| write_json("refs/heads/#{b[:name]}.json", b) }
+    end
+
+    def export_tags
+      tags = @repo.tags.filter_map do |tag|
+        target = tag.target
+        next unless target
+        commit = target.is_a?(Rugged::Tag::Annotation) ? target.target : target
+        { name: tag.name, sha: commit.oid, annotated: target.is_a?(Rugged::Tag::Annotation),
+          message: target.is_a?(Rugged::Tag::Annotation) ? target.message : nil,
+          committed_at: commit.committer[:time].iso8601 }
+      end.sort_by { |t| t[:committed_at] }.reverse
+      write_json("tags.json", tags)
+      tags.each { |t| write_json("refs/tags/#{t[:name]}.json", t) }
+    end
+
+    def export_commits
+      commits_list = []
+      walker = Rugged::Walker.new(@repo)
+      @repo.branches.each { |b| walker.push(b.target.oid) if b.target rescue nil }
+      walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_DATE)
+      walker.each_with_index do |commit, idx|
+        print "\r  Processing commits: #{idx + 1}" if ((idx + 1) % 50).zero?
+        data = extract_commit(commit)
+        commits_list << data.slice(:sha, :short_sha, :message_headline, :author, :committed_at)
+        write_json("commits/#{commit.oid}.json", data)
+        export_tree(commit.tree, "")
+      end
+      commits_list.sort_by! { |c| c[:committed_at] }.reverse!
+      write_json("commits.json", commits_list)
+      puts "\r  Processed #{commits_list.size} commits"
+    end
+
+    def extract_commit(commit)
+      stats = commit.parents.empty? ? initial_diff_stats(commit) : parent_diff_stats(commit)
+      { sha: commit.oid, short_sha: commit.oid[0, 7], message: commit.message,
+        message_headline: commit.message.lines.first&.strip || "",
+        author: { name: commit.author[:name], email: commit.author[:email], date: commit.author[:time].iso8601 },
+        committer: { name: commit.committer[:name], email: commit.committer[:email], date: commit.committer[:time].iso8601 },
+        committed_at: commit.committer[:time].iso8601,
+        parents: commit.parents.map { |p| { sha: p.oid, short_sha: p.oid[0, 7] } },
+        tree_sha: commit.tree.oid, stats: stats[:stats], files: stats[:files] }
+    end
+
+    def initial_diff_stats(commit)
+      files = []
+      collect_tree_files(commit.tree, "", files)
+      { stats: { additions: files.sum { |f| f[:additions] }, deletions: 0, changed: files.size }, files: files }
+    end
+
+    def collect_tree_files(tree, path, files)
+      tree.each do |entry|
+        full_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}"
+        if entry[:type] == :blob
+          blob = @repo.lookup(entry[:oid])
+          files << { path: full_path, additions: blob.binary? ? 0 : blob.content.lines.count, deletions: 0, status: "added" }
+        elsif entry[:type] == :tree
+          collect_tree_files(@repo.lookup(entry[:oid]), full_path, files)
+        end
+      end
+    end
+
+    def parent_diff_stats(commit)
+      diff = commit.parents.first.diff(commit)
+      files, additions, deletions = [], 0, 0
+      diff.each_patch do |patch|
+        fa, fd = 0, 0
+        patch.each_hunk { |h| h.each_line { |l| fa += 1 if l.addition?; fd += 1 if l.deletion? } }
+        additions += fa; deletions += fd
+        status = { added: "added", deleted: "deleted", renamed: "renamed" }[patch.delta.status] || "modified"
+        files << { path: patch.delta.new_file[:path], additions: fa, deletions: fd, status: status }
+      end
+      { stats: { additions: additions, deletions: deletions, changed: files.size }, files: files }
+    end
+
+    def export_tree(tree, path)
+      return if @processed_trees.include?(tree.oid)
+      @processed_trees.add(tree.oid)
+      entries = tree.map do |entry|
+        entry_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}"
+        export_tree(@repo.lookup(entry[:oid]), entry_path) if entry[:type] == :tree
+        export_blob(entry[:oid], entry_path) if entry[:type] == :blob
+        { name: entry[:name], path: entry_path, type: entry[:type].to_s, sha: entry[:oid], mode: entry[:filemode].to_s(8) }
+      end
+      write_json("trees/#{tree.oid}.json", { sha: tree.oid, path: path, entries: entries })
+    end
+
+    def export_blob(oid, path)
+      return if @processed_blobs.include?(oid)
+      @processed_blobs.add(oid)
+      blob = @repo.lookup(oid)
+      content = blob.binary? ? nil : blob.content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
+      content = "#{content[0, 100_000]}\n... [truncated]" if content && content.size > 100_000
+      write_json("blobs/#{oid}.json", { sha: oid, path: path, size: blob.size, binary: blob.binary?, content: content, truncated: !blob.binary? && blob.size > 100_000 })
+    end
+
+    def write_json(path, data)
+      File.write(File.join(@output_dir, path), JSON.generate(data))
+    end
+  end
+end
lib/gitem/index.html.erb → lib/gitem/index.html
File renamed without changes
lib/gitem/server.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitem
+  class Server
+    attr_reader :url
+
+    def initialize(root, port = 8000)
+      @root = root
+      @port = port
+      @url = "http://localhost:#{port}"
+    end
+
+    def start
+      puts "🌐 Server running at #{@url}"
+      puts "   Press Ctrl+C to stop\n\n"
+      server = WEBrick::HTTPServer.new(
+        Port: @port, DocumentRoot: @root,
+        Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN), AccessLog: []
+      )
+      trap("INT") { server.shutdown }
+      trap("TERM") { server.shutdown }
+      server.start
+    end
+  end
+end
lib/gitem/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 module Gitem
-  VERSION = "0.1.0"
+  VERSION = "0.2.0"
 end
lib/gitem.rb
@@ -1,250 +1,19 @@
 # frozen_string_literal: true
 
-require 'fileutils'
-require 'json'
-require 'rugged'
-require 'time'
+require "fileutils"
+require "json"
+require "optparse"
+require "rbconfig"
+require "rugged"
+require "set"
+require "time"
+require "webrick"
 
 require_relative "gitem/version"
+require_relative "gitem/generator"
+require_relative "gitem/server"
+require_relative "gitem/cli"
 
 module Gitem
   class Error < StandardError; end
-
-  class GitToJson
-    def initialize(repo_path, output_dir = nil)
-      @repo = Rugged::Repository.new(repo_path)
-      @output_dir = output_dir || File.join(@repo.path, 'srv')
-      @processed_trees = Set.new
-      @processed_blobs = Set.new
-    end
-
-    def export!
-      setup_directories
-      export_branches
-      export_tags
-      export_commits
-      export_repo_info
-      puts "\nāœ“ Export complete! Files written to #{@output_dir}"
-      puts "  Serve with: cd #{@output_dir} && ruby -run -e httpd . 8000"
-    end
-
-    private
-
-    def setup_directories
-      %w[commits trees blobs refs/heads refs/tags].each do |dir|
-        FileUtils.mkdir_p(File.join(@output_dir, dir))
-      end
-    end
-
-    def export_repo_info
-      default_branch = default_branch_name
-      branch = @repo.branches[default_branch]
-      readme_content = nil
-      readme_name = nil
-
-      if branch&.target
-        tree = branch.target.tree
-        %w[README.md README.markdown readme.md README.txt README].each do |name|
-          entry = tree.each.find { |e| e[:name].downcase == name.downcase }
-          if entry && entry[:type] == :blob
-            blob = @repo.lookup(entry[:oid])
-            readme_content = blob.content.encode('UTF-8', invalid: :replace, undef: :replace) unless blob.binary?
-            readme_name = entry[:name]
-            break
-          end
-        end
-      end
-
-      info = {
-        name: File.basename(@repo.workdir || @repo.path.chomp('/.git/').chomp('.git')),
-        default_branch: default_branch,
-        branches_count: @repo.branches.count { |b| b.name && !b.name.include?('/') },
-        tags_count: @repo.tags.count,
-        readme: readme_content,
-        readme_name: readme_name,
-        generated_at: Time.now.iso8601
-      }
-      write_json('repo.json', info)
-    end
-
-    def default_branch_name
-      %w[main master].find { |name| @repo.branches[name] } || @repo.branches.first&.name || 'main'
-    end
-
-    def export_branches
-      branches = @repo.branches.map do |branch|
-        next if branch.name.nil? || branch.name.include?('/')
-        target = branch.target rescue nil
-        next unless target
-        {
-          name: branch.name,
-          sha: target.oid,
-          is_head: branch.head?,
-          committed_at: target.committer[:time].iso8601,
-          author: target.author[:name],
-          message: target.message.lines.first&.strip || ''
-        }
-      end.compact.sort_by { |b| b[:is_head] ? 0 : 1 }
-
-      write_json('branches.json', branches)
-      branches.each { |b| write_json("refs/heads/#{b[:name]}.json", b) }
-    end
-
-    def export_tags
-      tags = @repo.tags.map do |tag|
-        target = tag.target rescue nil
-        next unless target
-        commit = target.is_a?(Rugged::Tag::Annotation) ? target.target : target
-        {
-          name: tag.name,
-          sha: commit.oid,
-          annotated: target.is_a?(Rugged::Tag::Annotation),
-          message: target.is_a?(Rugged::Tag::Annotation) ? target.message : nil,
-          committed_at: commit.committer[:time].iso8601
-        }
-      end.compact.sort_by { |t| t[:committed_at] }.reverse
-
-      write_json('tags.json', tags)
-      tags.each { |t| write_json("refs/tags/#{t[:name]}.json", t) }
-    end
-
-    def export_commits
-      commits_list = []
-      walker = Rugged::Walker.new(@repo)
-
-      @repo.branches.each do |branch|
-        next if branch.target.nil?
-        walker.push(branch.target.oid) rescue nil
-      end
-
-      walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_DATE)
-
-      walker.each_with_index do |commit, idx|
-        print "\rProcessing commit #{idx + 1}..." if (idx + 1) % 10 == 0
-
-        commit_data = extract_commit(commit)
-        commits_list << commit_data.slice(:sha, :short_sha, :message_headline, :author, :committed_at)
-
-        write_json("commits/#{commit.oid}.json", commit_data)
-        export_tree(commit.tree, '')
-      end
-
-      commits_list.sort_by! { |c| c[:committed_at] }.reverse!
-      write_json('commits.json', commits_list)
-      puts "\rProcessed #{commits_list.size} commits"
-    end
-
-    def extract_commit(commit)
-      parents = commit.parents.map { |p| { sha: p.oid, short_sha: p.oid[0..6] } }
-      diff_stats = commit.parents.empty? ? initial_diff_stats(commit) : parent_diff_stats(commit)
-
-      {
-        sha: commit.oid,
-        short_sha: commit.oid[0..6],
-        message: commit.message,
-        message_headline: commit.message.lines.first&.strip || '',
-        author: { name: commit.author[:name], email: commit.author[:email], date: commit.author[:time].iso8601 },
-        committer: { name: commit.committer[:name], email: commit.committer[:email], date: commit.committer[:time].iso8601 },
-        committed_at: commit.committer[:time].iso8601,
-        parents: parents,
-        tree_sha: commit.tree.oid,
-        stats: diff_stats[:stats],
-        files: diff_stats[:files]
-      }
-    end
-
-    def initial_diff_stats(commit)
-      files = []
-      collect_tree_files(commit.tree, '', files)
-      { stats: { additions: files.sum { |f| f[:additions] }, deletions: 0, changed: files.size }, files: files }
-    end
-
-    def collect_tree_files(tree, path, files)
-      tree.each do |entry|
-        full_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}"
-        if entry[:type] == :blob
-          blob = @repo.lookup(entry[:oid])
-          lines = blob.binary? ? 0 : blob.content.lines.count
-          files << { path: full_path, additions: lines, deletions: 0, status: 'added' }
-        elsif entry[:type] == :tree
-          collect_tree_files(@repo.lookup(entry[:oid]), full_path, files)
-        end
-      end
-    end
-
-    def parent_diff_stats(commit)
-      diff = commit.parents.first.diff(commit)
-      files = []
-      additions = deletions = 0
-
-      diff.each_patch do |patch|
-        file_adds = file_dels = 0
-        patch.each_hunk { |h| h.each_line { |l| l.addition? ? file_adds += 1 : (file_dels += 1 if l.deletion?) } }
-        additions += file_adds
-        deletions += file_dels
-
-        status = case patch.delta.status
-        when :added then 'added'
-        when :deleted then 'deleted'
-        when :renamed then 'renamed'
-        else 'modified'
-        end
-
-        files << { path: patch.delta.new_file[:path], additions: file_adds, deletions: file_dels, status: status }
-      end
-
-      { stats: { additions: additions, deletions: deletions, changed: files.size }, files: files }
-    end
-
-    def export_tree(tree, path)
-      return if @processed_trees.include?(tree.oid)
-      @processed_trees.add(tree.oid)
-
-      entries = tree.map do |entry|
-        entry_data = {
-          name: entry[:name],
-          path: path.empty? ? entry[:name] : "#{path}/#{entry[:name]}",
-          type: entry[:type].to_s,
-          sha: entry[:oid],
-          mode: entry[:filemode].to_s(8)
-        }
-
-        if entry[:type] == :tree
-          export_tree(@repo.lookup(entry[:oid]), entry_data[:path])
-        elsif entry[:type] == :blob
-          export_blob(entry[:oid], entry_data[:path])
-        end
-
-        entry_data
-      end
-
-      write_json("trees/#{tree.oid}.json", { sha: tree.oid, path: path, entries: entries })
-    end
-
-    def export_blob(oid, path)
-      return if @processed_blobs.include?(oid)
-      @processed_blobs.add(oid)
-
-      blob = @repo.lookup(oid)
-      data = {
-        sha: oid,
-        path: path,
-        size: blob.size,
-        binary: blob.binary?,
-        content: blob.binary? ? nil : safe_content(blob.content),
-        truncated: !blob.binary? && blob.size > 100_000
-      }
-
-      write_json("blobs/#{oid}.json", data)
-    end
-
-    def safe_content(content)
-      return content[0..100_000] + "\n... [truncated]" if content.size > 100_000
-      content.encode('UTF-8', invalid: :replace, undef: :replace, replace: 'ļæ½')
-    end
-
-    def write_json(path, data)
-      File.write(File.join(@output_dir, path), JSON.pretty_generate(data))
-    end
-  end
 end
Gemfile.lock
@@ -1,12 +1,11 @@
 PATH
   remote: .
   specs:
-    gitem (0.1.0)
+    gitem (0.2.0)
       fileutils (~> 1.0)
       json (~> 2.0)
-      open3 (~> 0.1)
       rugged (~> 1.0)
-      time (~> 0.1)
+      webrick (~> 1.8)
 
 GEM
   remote: https://rubygems.org/
@@ -21,7 +20,6 @@ GEM
       rdoc (>= 4.0.0)
       reline (>= 0.4.2)
     json (2.18.0)
-    open3 (0.2.1)
     pp (0.6.3)
       prettyprint
     prettyprint (0.2.0)
@@ -50,9 +48,8 @@ GEM
     rspec-support (3.13.6)
     rugged (1.9.0)
     stringio (3.1.9)
-    time (0.4.1)
-      date
     tsort (0.2.0)
+    webrick (1.9.1)
 
 PLATFORMS
   ruby
@@ -69,11 +66,10 @@ CHECKSUMS
   diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
   erb (6.0.0) sha256=2730893f9d8c9733f16cab315a4e4b71c1afa9cabc1a1e7ad1403feba8f52579
   fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
-  gitem (0.1.0)
+  gitem (0.2.0)
   io-console (0.8.1) sha256=1e15440a6b2f67b6ea496df7c474ed62c860ad11237f29b3bd187f054b925fcb
   irb (1.15.3) sha256=4349edff1efa7ff7bfd34cb9df74a133a588ba88c2718098b3b4468b81184aaa
   json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
-  open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952
   pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
   prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
   psych (5.3.0) sha256=8976a41ae29ea38c88356e862629345290347e3bfe27caf654f7c5a920e95eeb
@@ -87,8 +83,8 @@ CHECKSUMS
   rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2
   rugged (1.9.0) sha256=7faaa912c5888d6e348d20fa31209b6409f1574346b1b80e309dbc7e8d63efac
   stringio (3.1.9) sha256=c111af13d3a73eab96a3bc2655ecf93788d13d28cb8e25c1dcbff89ace885121
-  time (0.4.1) sha256=035f360508a4a4dbabcbbcd3886566b9abd432de89136795d2ff7aec5bcdea61
   tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+  webrick (1.9.1)
 
 BUNDLED WITH
   4.0.1
gitem.gemspec
@@ -8,15 +8,16 @@ Gem::Specification.new do |spec|
   spec.authors = ["mo khan"]
   spec.email = ["mo@mokhan.ca"]
 
-  spec.summary = "A static site generated for git repositories."
-  spec.description = "A static site generated for git repositories."
-  spec.homepage = "https://mokhan.ca/xlgmokha/gitem"
+  spec.summary = "A static site generator for git repositories."
+  spec.description = "Browse your git history locally with a GitHub-like interface."
+  spec.homepage = "https://github.com/xlgmokha/gitem"
   spec.license = "MIT"
   spec.required_ruby_version = ">= 3.4.0"
 
   spec.metadata["allowed_push_host"] = "https://rubygems.org"
   spec.metadata["homepage_uri"] = spec.homepage
-  spec.metadata["source_code_uri"] = "https://mokhan.ca/xlgmokha/gitem"
+  spec.metadata["source_code_uri"] = "https://github.com/xlgmokha/gitem"
+  spec.metadata["changelog_uri"] = "https://github.com/xlgmokha/gitem/blob/main/CHANGELOG.md"
 
   # Specify which files should be added to the gem when it is released.
   # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -33,7 +34,6 @@ Gem::Specification.new do |spec|
 
   spec.add_dependency "fileutils", "~> 1.0"
   spec.add_dependency "json", "~> 2.0"
-  spec.add_dependency "open3", "~> 0.1"
   spec.add_dependency "rugged", "~> 1.0"
-  spec.add_dependency "time", "~> 0.1"
+  spec.add_dependency "webrick", "~> 1.8"
 end
README.md
@@ -1,39 +1,48 @@
 # Gitem
 
-TODO: Delete this and the text below, and describe your gem
-
-Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/gitem`. To experiment with that code, run `bin/console` for an interactive prompt.
+A static site generator for git repositories. Browse your git history locally with a GitHub-like interface.
 
 ## Installation
 
-TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
-
-Install the gem and add to the application's Gemfile by executing:
-
-```bash
-bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
+```
+gem install gitem
 ```
 
-If bundler is not being used to manage dependencies, install the gem by executing:
+## Usage
 
-```bash
-gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
+```
+gitem serve                # Generate and serve (default)
+gitem serve -p 3000        # Custom port
+gitem serve --open         # Open browser
+gitem serve --no-generate  # Serve only
+gitem generate             # Generate only
+gitem generate -o ./out    # Custom output
 ```
 
-## Usage
+## Requirements
 
-TODO: Write usage instructions here
+- Ruby >= 3.4.0
+- libgit2
 
-## Development
+### macOS
 
-After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
+```
+brew install libgit2
+```
 
-To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
+### Ubuntu/Debian
+
+```
+apt-get install libgit2-dev cmake
+```
 
-## Contributing
+## Development
 
-Bug reports and pull requests are welcome on GitHub at https://github.com/xlgmokha/gitem.
+```
+bin/setup
+rake spec
+```
 
 ## License
 
-The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
+[MIT](https://opensource.org/licenses/MIT)